From 0eafb332fd7a9cd7d07348586d2e2a73c0530838 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 30 Mar 2026 19:51:59 -0700 Subject: [PATCH] improvement(workflows): replace Zustand workflow sync with React Query as single source of truth --- .../components/sandbox-canvas-provider.tsx | 39 +- .../add-resource-dropdown.tsx | 2 +- .../resource-content/resource-content.tsx | 9 +- .../resource-registry/resource-registry.tsx | 13 +- .../resource-tabs/resource-tabs.tsx | 2 +- .../home/components/user-input/user-input.tsx | 4 +- .../user-message-content.tsx | 16 +- .../[workspaceId]/home/hooks/use-chat.ts | 44 +- .../workflows-list/workflows-list.tsx | 6 +- .../logs/components/dashboard/dashboard.tsx | 8 +- .../workflow-selector/workflow-selector.tsx | 4 +- .../logs-toolbar/components/search/search.tsx | 10 +- .../components/logs-toolbar/logs-toolbar.tsx | 8 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 10 +- .../recently-deleted/recently-deleted.tsx | 2 +- .../components/user-input/constants.ts | 1 - .../user-input/hooks/use-mention-data.ts | 10 +- .../general/components/api-info-modal.tsx | 19 +- .../components/deploy-modal/deploy-modal.tsx | 9 +- .../panel/components/deploy/deploy.tsx | 5 +- .../components/tools/credential-selector.tsx | 6 +- .../components/tool-input/tool-input.tsx | 2 +- .../w/[workflowId]/components/panel/panel.tsx | 37 +- .../workflow-block/workflow-block.tsx | 12 +- .../hooks/use-workflow-execution.ts | 14 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 9 +- .../components/block/block.tsx | 6 +- .../components/folder-item/folder-item.tsx | 7 +- .../workflow-item/workflow-item.tsx | 17 +- .../components/sidebar/hooks/use-drag-drop.ts | 11 +- .../sidebar/hooks/use-workflow-operations.ts | 11 +- .../w/components/sidebar/sidebar.tsx | 10 +- .../[workspaceId]/w/hooks/use-can-delete.ts | 10 +- .../w/hooks/use-delete-selection.ts | 18 +- .../w/hooks/use-delete-workflow.ts | 23 +- .../w/hooks/use-duplicate-selection.ts | 7 +- .../w/hooks/use-duplicate-workflow.ts | 7 +- .../w/hooks/use-export-folder.ts | 6 +- .../w/hooks/use-export-selection.ts | 11 +- .../w/hooks/use-export-workflow.ts | 12 +- .../app/workspace/[workspaceId]/w/page.tsx | 21 +- .../handlers/workflow/workflow-handler.ts | 9 +- apps/sim/hooks/queries/custom-tools.ts | 7 +- apps/sim/hooks/queries/folders.ts | 11 +- .../utils/get-workspace-id-from-url.ts | 10 + apps/sim/hooks/queries/utils/workflow-keys.ts | 13 + apps/sim/hooks/queries/workflows.ts | 670 +++++++++++------- apps/sim/hooks/selectors/registry.ts | 18 +- .../tools/client/tool-display-registry.ts | 3 +- apps/sim/lib/core/utils/optimistic-update.ts | 103 --- apps/sim/stores/index.ts | 1 - apps/sim/stores/workflows/index.ts | 19 +- apps/sim/stores/workflows/registry/store.ts | 404 +---------- apps/sim/stores/workflows/registry/types.ts | 15 +- 54 files changed, 742 insertions(+), 1019 deletions(-) create mode 100644 apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts create mode 100644 apps/sim/hooks/queries/utils/workflow-keys.ts delete mode 100644 apps/sim/lib/core/utils/optimistic-update.ts diff --git a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx index 62edc38bc76..3bd682f43ef 100644 --- a/apps/sim/app/academy/components/sandbox-canvas-provider.tsx +++ b/apps/sim/app/academy/components/sandbox-canvas-provider.tsx @@ -13,10 +13,12 @@ import type { import { validateExercise } from '@/lib/academy/validation' import { cn } from '@/lib/core/utils/cn' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow' import { getBlock } from '@/blocks/registry' +import { workflowKeys } from '@/hooks/queries/workflows' import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints' import { useExecutionStore } from '@/stores/execution/store' import { useTerminalConsoleStore } from '@/stores/terminal/console/store' @@ -218,8 +220,13 @@ export function SandboxCanvasProvider({ useWorkflowStore.getState().replaceWorkflowState(workflowState) useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks) - useWorkflowRegistry.setState((state) => ({ - workflows: { ...state.workflows, [workflowId]: syntheticMetadata }, + + const qc = getQueryClient() + const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cached = qc.getQueryData(cacheKey) ?? [] + qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata]) + + useWorkflowRegistry.setState({ activeWorkflowId: workflowId, hydration: { phase: 'ready', @@ -228,7 +235,7 @@ export function SandboxCanvasProvider({ requestId: null, error: null, }, - })) + }) logger.info('Sandbox stores hydrated', { workflowId }) setIsReady(true) @@ -262,17 +269,21 @@ export function SandboxCanvasProvider({ unsubWorkflow() unsubSubBlock() unsubExecution() - useWorkflowRegistry.setState((state) => { - const { [workflowId]: _removed, ...rest } = state.workflows - return { - workflows: rest, - activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, - hydration: - state.hydration.workflowId === workflowId - ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } - : state.hydration, - } - }) + const cleanupQc = getQueryClient() + const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active') + const cleanupCached = cleanupQc.getQueryData(cleanupKey) ?? [] + cleanupQc.setQueryData( + cleanupKey, + cleanupCached.filter((w) => w.id !== workflowId) + ) + + useWorkflowRegistry.setState((state) => ({ + activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId, + hydration: + state.hydration.workflowId === workflowId + ? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null } + : state.hydration, + })) useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} }) useSubBlockStore.setState((state) => { const { [workflowId]: _removed, ...rest } = state.workflowValues diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index bd0cf8cc792..821d6c47242 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -47,7 +47,7 @@ export function useAvailableResources( workspaceId: string, existingKeys: Set ): AvailableItemsByType[] { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 6e5913caa1e..f6a0edd5628 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -37,6 +37,7 @@ import { import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' +import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useExecutionStore } from '@/stores/execution/store' @@ -375,10 +376,12 @@ interface EmbeddedWorkflowProps { } function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) { - const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId])) - const isMetadataLoaded = useWorkflowRegistry( - (state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading' + const { data: workflowList } = useWorkflows(workspaceId) + const workflowExists = useMemo( + () => (workflowList ?? []).some((w) => w.id === workflowId), + [workflowList, workflowId] ) + const isMetadataLoaded = useWorkflowRegistry((state) => state.hydration.phase !== 'idle') const hasLoadError = useWorkflowRegistry( (state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 4b545dc298b..01126734d21 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -1,7 +1,8 @@ 'use client' -import type { ElementType, ReactNode } from 'react' +import { type ElementType, type ReactNode, useMemo } from 'react' import type { QueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { Database, File as FileIcon, @@ -17,9 +18,8 @@ import type { } from '@/app/workspace/[workspaceId]/home/types' import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { tableKeys } from '@/hooks/queries/tables' -import { workflowKeys } from '@/hooks/queries/workflows' +import { useWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface DropdownItemRenderProps { item: { id: string; name: string; [key: string]: unknown } @@ -34,7 +34,12 @@ export interface ResourceTypeConfig { } function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) { - const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888') + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList } = useWorkflows(workspaceId) + const color = useMemo(() => { + const wf = (workflowList ?? []).find((w) => w.id === workflowId) + return wf?.color ?? '#888' + }, [workflowList, workflowId]) return (
= { * tabs always reflect the latest name even after a rename. */ function useResourceNameLookup(workspaceId: string): Map { - const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflows = [] } = useWorkflows(workspaceId) const { data: tables = [] } = useTablesList(workspaceId) const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 2735afc993e..9fb923d97b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -45,8 +45,8 @@ import { computeMentionHighlightRanges, extractContextTokens, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils' +import { getWorkflows } from '@/hooks/queries/workflows' import type { ChatContext } from '@/stores/panel' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' @@ -639,7 +639,7 @@ export function UserInput({ case 'workflow': case 'current_workflow': { const wfId = (matchingCtx as { workflowId: string }).workflowId - const wfColor = useWorkflowRegistry.getState().workflows[wfId]?.color ?? '#888' + const wfColor = getWorkflows().find((w) => w.id === wfId)?.color ?? '#888' mentionIconNode = (
{ - if (context.kind === 'workflow' || context.kind === 'current_workflow') { - return state.workflows[context.workflowId || '']?.color ?? null - } - return null - }) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList } = useWorkflows(workspaceId) + const workflowColor = useMemo(() => { + if (context.kind !== 'workflow' && context.kind !== 'current_workflow') return null + return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null + }, [workflowList, context.kind, context.workflowId]) let icon: React.ReactNode = null const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 3b3511ab0f3..5a86fded598 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -21,6 +21,7 @@ import { import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' import { getNextWorkflowColor } from '@/lib/workflows/colors' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { deploymentKeys } from '@/hooks/queries/deployments' import { @@ -35,7 +36,7 @@ import { useChatHistory, } from '@/hooks/queries/tasks' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' +import { getWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' import { useFolderStore } from '@/stores/folders/store' @@ -301,31 +302,32 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined { return typeof payload.data === 'object' ? payload.data : undefined } -/** Adds a workflow to the registry with a top-insertion sort order if it doesn't already exist. */ +/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean { - const registry = useWorkflowRegistry.getState() - if (registry.workflows[resourceId]) return false + const workflows = getWorkflows(workspaceId) + if (workflows.find((w) => w.id === resourceId)) return false const sortOrder = getTopInsertionSortOrder( - registry.workflows, + Object.fromEntries(workflows.map((w) => [w.id, w])), useFolderStore.getState().folders, workspaceId, null ) - useWorkflowRegistry.setState((state) => ({ - workflows: { - ...state.workflows, - [resourceId]: { - id: resourceId, - name: title, - lastModified: new Date(), - createdAt: new Date(), - color: getNextWorkflowColor(), - workspaceId, - folderId: null, - sortOrder, - }, - }, - })) + const newMetadata: import('@/stores/workflows/registry/types').WorkflowMetadata = { + id: resourceId, + name: title, + lastModified: new Date(), + createdAt: new Date(), + color: getNextWorkflowColor(), + workspaceId, + folderId: null, + sortOrder, + } + const queryClient = getQueryClient() + const key = workflowKeys.list(workspaceId, 'active') + const current = + queryClient.getQueryData(key) ?? + [] + queryClient.setQueryData(key, [...current, newMetadata]) return true } @@ -1253,7 +1255,7 @@ export function useChat( ? ((args as Record).workflowId as string) : useWorkflowRegistry.getState().activeWorkflowId if (targetWorkflowId) { - const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId] + const meta = getWorkflows().find((w) => w.id === targetWorkflowId) const wasAdded = addResource({ type: 'workflow', id: targetWorkflowId, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 70915c2b8f3..ee52e0fa0bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -1,10 +1,11 @@ import { memo } from 'react' +import { useParams } from 'next/navigation' import { cn } from '@/lib/core/utils/cn' import { DELETED_WORKFLOW_COLOR, DELETED_WORKFLOW_LABEL, } from '@/app/workspace/[workspaceId]/logs/utils' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { StatusBar, type StatusBarSegment } from '..' export interface WorkflowExecutionItem { @@ -36,7 +37,8 @@ function WorkflowsListInner({ searchQuery: string segmentDurationMs: number }) { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index e19df1fc194..c21184c9cc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -2,12 +2,13 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2 } from 'lucide-react' +import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { Skeleton } from '@/components/emcn' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFilterStore } from '@/stores/logs/filters/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { LineChart, WorkflowsList } from './components' interface WorkflowExecution { @@ -156,7 +157,8 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { })) ) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null @@ -459,7 +461,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { ) } - if (Object.keys(allWorkflows).length === 0) { + if (allWorkflowList.length === 0) { return (
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx index 02c3173bd83..f0172a1ee17 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/components/workflow-selector/workflow-selector.tsx @@ -25,9 +25,7 @@ export function WorkflowSelector({ onChange, error, }: WorkflowSelectorProps) { - const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId, { - syncRegistry: false, - }) + const { data: workflows = [], isPending: isLoading } = useWorkflows(workspaceId) const options: ComboboxOption[] = useMemo(() => { return workflows.map((w) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx index cb8b795276b..5bce8435212 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/search/search.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' import { Search, X } from 'lucide-react' +import { useParams } from 'next/navigation' import { Badge } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' @@ -14,8 +15,8 @@ import { type WorkflowData, } from '@/lib/logs/search-suggestions' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' function truncateFilterValue(field: string, value: string): string { if ((field === 'executionId' || field === 'workflowId') && value.length > 12) { @@ -42,16 +43,17 @@ export function AutocompleteSearch({ className, onOpenChange, }: AutocompleteSearchProps) { - const workflows = useWorkflowRegistry((state) => state.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflowList = [] } = useWorkflows(workspaceId) const folders = useFolderStore((state) => state.folders) const workflowsData = useMemo(() => { - return Object.values(workflows).map((w) => ({ + return workflowList.map((w) => ({ id: w.id, name: w.name, description: w.description, })) - }, [workflows]) + }, [workflowList]) const foldersData = useMemo(() => { return Object.values(folders).map((f) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 542a64ca0ed..e529e74803f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -20,10 +20,10 @@ import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils' import { getBlock } from '@/blocks/registry' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { AutocompleteSearch } from './components/search' const TIME_RANGE_OPTIONS: ComboboxOption[] = [ @@ -220,15 +220,15 @@ export const LogsToolbar = memo(function LogsToolbar({ const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) const folders = useFolderStore((state) => state.folders) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo(() => { - return Object.values(allWorkflows).map((w) => ({ + return allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color, })) - }, [allWorkflows]) + }, [allWorkflowList]) const folderList = useMemo(() => { return Object.values(folders).filter((f) => f.workspaceId === workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 85de3bebd0e..c3add8a777a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -57,12 +57,12 @@ import { useLogDetail, useLogsList, } from '@/hooks/queries/logs' +import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useDebounce } from '@/hooks/use-debounce' import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import type { WorkflowLog } from '@/stores/logs/filters/types' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { Dashboard, ExecutionSnapshot, @@ -774,7 +774,7 @@ export default function Logs() { ] ) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflows = {} } = useWorkflowMap(workspaceId) const folders = useFolderStore((state) => state.folders) const filterTags = useMemo(() => { @@ -1234,11 +1234,11 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) const folders = useFolderStore((state) => state.folders) - const allWorkflows = useWorkflowRegistry((state) => state.workflows) + const { data: allWorkflowList = [] } = useWorkflows(workspaceId) const workflows = useMemo( - () => Object.values(allWorkflows).map((w) => ({ id: w.id, name: w.name, color: w.color })), - [allWorkflows] + () => allWorkflowList.map((w) => ({ id: w.id, name: w.name, color: w.color })), + [allWorkflowList] ) const folderList = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 8fdb3500bcc..86b933a18d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -119,7 +119,7 @@ export function RecentlyDeleted() { const [restoringIds, setRestoringIds] = useState>(new Set()) const [restoredItems, setRestoredItems] = useState>(new Map()) - const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' }) + const workflowsQuery = useWorkflows(workspaceId, { scope: 'archived' }) const tablesQuery = useTablesList(workspaceId, 'archived') const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' }) const filesQuery = useWorkspaceFiles(workspaceId, 'archived') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts index 72fc8e3027b..cdced3555eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/constants.ts @@ -70,7 +70,6 @@ export const FOLDER_CONFIGS: Record = { title: 'All workflows', dataKey: 'workflows', loadingKey: 'isLoadingWorkflows', - // No ensureLoadedKey - workflows auto-load from registry store getLabel: (item) => item.name || 'Untitled Workflow', getId: (item) => item.id, emptyMessage: 'No workflows', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts index 15f5007b6bb..687deb61e86 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' import { useShallow } from 'zustand/react/shallow' +import { useWorkflows } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -151,14 +152,11 @@ export function useMentionData(props: UseMentionDataProps): MentionDataReturn { useShallow(useCallback((state) => Object.keys(state.blocks), [])) ) - const registryWorkflows = useWorkflowRegistry(useShallow((state) => state.workflows)) + const { data: registryWorkflowList = [] } = useWorkflows(workspaceId) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isLoadingWorkflows = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isLoadingWorkflows = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' - const workflows: WorkflowItem[] = Object.values(registryWorkflows) + const workflows: WorkflowItem[] = registryWorkflowList .filter((w) => w.workspaceId === workspaceId) .sort((a, b) => { const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx index c701e0bbb6e..12224bbc503 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'next/navigation' import { Badge, Button, @@ -19,6 +20,7 @@ import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import type { InputFormatField } from '@/lib/workflows/types' import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments' +import { useUpdateWorkflow, useWorkflowMap } from '@/hooks/queries/workflows' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -33,16 +35,16 @@ interface ApiInfoModalProps { } export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) { + const { workspaceId } = useParams<{ workspaceId: string }>() const blocks = useWorkflowStore((state) => state.blocks) const setValue = useSubBlockStore((state) => state.setValue) const subBlockValues = useSubBlockStore((state) => workflowId ? (state.workflowValues[workflowId] ?? {}) : {} ) - const workflowMetadata = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : undefined - ) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const workflowMetadata = workflowId ? workflows[workflowId] : undefined + const updateWorkflowMutation = useUpdateWorkflow() const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open }) const updatePublicApiMutation = useUpdatePublicApi() @@ -175,7 +177,11 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } if (description.trim() !== (workflowMetadata?.description || '')) { - updateWorkflow(workflowId, { description: description.trim() || 'New workflow' }) + updateWorkflowMutation.mutate({ + workspaceId, + workflowId, + metadata: { description: description.trim() || 'New workflow' }, + }) } if (starterBlockId) { @@ -195,16 +201,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro } }, [ workflowId, + workspaceId, description, workflowMetadata, - updateWorkflow, starterBlockId, inputFormat, paramDescriptions, setValue, onOpenChange, accessMode, - updatePublicApiMutation, ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx index ed55ce78817..6e228f22c39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { Badge, Button, @@ -35,6 +36,7 @@ import { } from '@/hooks/queries/deployments' // import { useTemplateByWorkflow } from '@/hooks/queries/templates' import { useWorkflowMcpServers } from '@/hooks/queries/workflow-mcp-servers' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useWorkspaceSettings } from '@/hooks/queries/workspace' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -85,14 +87,15 @@ export function DeployModal({ isLoadingDeployedState, }: DeployModalProps) { const queryClient = useQueryClient() + const params = useParams() + const workspaceId = params?.workspaceId as string const { navigateToSettings } = useSettingsNavigation() const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(workflowId) ) const isDeployed = deploymentStatus?.isDeployed ?? isDeployedProp - const workflowMetadata = useWorkflowRegistry((state) => - workflowId ? state.workflows[workflowId] : undefined - ) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) + const workflowMetadata = workflowId ? workflowMap[workflowId] : undefined const workflowWorkspaceId = workflowMetadata?.workspaceId ?? null const [activeTab, setActiveTab] = useState('general') const [chatSubmitting, setChatSubmitting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 3285337ab96..78081e6eeac 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -22,10 +22,7 @@ interface DeployProps { export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) - const isRegistryLoading = - hydrationPhase === 'idle' || - hydrationPhase === 'metadata-loading' || - hydrationPhase === 'state-loading' + const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() const deploymentStatus = useWorkflowRegistry((state) => diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx index d7dce93c7f9..af69db47468 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx @@ -18,6 +18,7 @@ import { ConnectCredentialModal } from '@/app/workspace/[workspaceId]/w/[workflo import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal' import { useWorkspaceCredential } from '@/hooks/queries/credentials' import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -77,9 +78,10 @@ export function ToolCredentialSelector({ const [showOAuthModal, setShowOAuthModal] = useState(false) const [editingInputValue, setEditingInputValue] = useState('') const [isEditing, setIsEditing] = useState(false) - const { activeWorkflowId, workflows } = useWorkflowRegistry() + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) + const { data: workflowMap = {} } = useWorkflowMap(workspaceId) const effectiveWorkflowId = - activeWorkflowId && workflows[activeWorkflowId] ? activeWorkflowId : undefined + activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined const selectedId = value || '' const effectiveLabel = label || `Select ${getProviderName(provider)} account` diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 0adb1a286bf..7c77db6e61c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -500,7 +500,7 @@ export const ToolInput = memo(function ToolInput({ const availableEnvVars = useAvailableEnvVarKeys(workspaceId) const mcpDataLoading = mcpLoading || mcpServersLoading - const { data: workflowsList = [] } = useWorkflows(workspaceId, { syncRegistry: false }) + const { data: workflowsList = [] } = useWorkflows(workspaceId) const availableWorkflows = useMemo( () => workflowsList.filter((w) => w.id !== workflowId), [workflowsList, workflowId] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 05eb824623c..83474ab234d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -56,6 +56,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -126,18 +127,15 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const userPermissions = useUserPermissionsContext() const { config: permissionConfig } = usePermissionConfig() const { isImporting, handleFileChange } = useImportWorkflow({ workspaceId }) - const { workflows, activeWorkflowId, duplicateWorkflow, hydration } = useWorkflowRegistry( + const duplicateWorkflowMutation = useDuplicateWorkflowMutation() + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { activeWorkflowId, hydration } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, - duplicateWorkflow: state.duplicateWorkflow, hydration: state.hydration, })) ) - const isRegistryLoading = - hydration.phase === 'idle' || - hydration.phase === 'metadata-loading' || - hydration.phase === 'state-loading' + const isRegistryLoading = hydration.phase === 'idle' || hydration.phase === 'state-loading' const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) // Check for locked blocks (disables auto-layout) @@ -519,11 +517,21 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel return } + const sourceWorkflow = workflows[activeWorkflowId] + if (!sourceWorkflow) return + setIsDuplicating(true) try { - const newWorkflow = await duplicateWorkflow(activeWorkflowId) - if (newWorkflow) { - router.push(`/workspace/${workspaceId}/w/${newWorkflow}`) + const result = await duplicateWorkflowMutation.mutateAsync({ + workspaceId, + sourceId: activeWorkflowId, + name: `${sourceWorkflow.name} (copy)`, + description: sourceWorkflow.description, + color: sourceWorkflow.color ?? '', + folderId: sourceWorkflow.folderId, + }) + if (result?.id) { + router.push(`/workspace/${workspaceId}/w/${result.id}`) } } catch (error) { logger.error('Error duplicating workflow:', error) @@ -531,14 +539,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel setIsDuplicating(false) setIsMenuOpen(false) } - }, [ - activeWorkflowId, - userPermissions.canEdit, - isDuplicating, - duplicateWorkflow, - router, - workspaceId, - ]) + }, [activeWorkflowId, userPermissions.canEdit, isDuplicating, workflows, router, workspaceId]) /** * Toggles the locked state of all blocks in the workflow diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 8f7b8b80d0b..135efbf3348 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -46,9 +46,9 @@ import { useCredentialName } from '@/hooks/queries/oauth/oauth-credentials' import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules' import { useSkills } from '@/hooks/queries/skills' import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { useVariablesStore } from '@/stores/panel' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { wouldCreateCycle } from '@/stores/workflows/workflow/utils' @@ -600,11 +600,11 @@ const SubBlockRow = memo(function SubBlockRow({ ) const knowledgeBaseDisplayName = kbForDisplayName?.name ?? null - const workflowMap = useWorkflowRegistry((state) => state.workflows) - const workflowSelectionName = - subBlock?.id === 'workflowId' && typeof rawValue === 'string' - ? (workflowMap[rawValue]?.name ?? null) - : null + const { data: workflowListForLookup } = useWorkflows(workspaceId) + const workflowSelectionName = useMemo(() => { + if (subBlock?.id !== 'workflowId' || typeof rawValue !== 'string') return null + return (workflowListForLookup ?? []).find((w) => w.id === rawValue)?.name ?? null + }, [workflowListForLookup, subBlock?.id, rawValue]) const { data: mcpServers = [] } = useMcpServers(workspaceId || '') const mcpServerDisplayName = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 08bb15ab3b9..3380e47481c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'next/navigation' import { v4 as uuidv4 } from 'uuid' import { useShallow } from 'zustand/react/shallow' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' @@ -30,6 +31,7 @@ import type { BlockLog, BlockState, ExecutionResult, StreamingExecution } from ' import { hasExecutionResult } from '@/executor/utils/errors' import { coerceValue } from '@/executor/utils/start-block' import { subscriptionKeys } from '@/hooks/queries/subscription' +import { getWorkflows } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { WorkflowValidationError } from '@/serializer' import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution' @@ -102,11 +104,10 @@ function normalizeErrorMessage(error: unknown): string { } export function useWorkflowExecution() { + const { workspaceId: routeWorkspaceId } = useParams<{ workspaceId: string }>() const queryClient = useQueryClient() const currentWorkflow = useCurrentWorkflow() - const { activeWorkflowId, workflows } = useWorkflowRegistry( - useShallow((s) => ({ activeWorkflowId: s.activeWorkflowId, workflows: s.workflows })) - ) + const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const { toggleConsole, addConsole, updateConsole, cancelRunningEntries, clearExecutionEntries } = useTerminalConsoleStore( useShallow((s) => ({ @@ -382,13 +383,15 @@ export function useWorkflowExecution() { // Sandbox exercises have no real workflow — signal the SandboxCanvasProvider // to run mock execution by setting isExecuting, then bail out immediately. - if (workflows[activeWorkflowId]?.isSandbox) { + const cachedWorkflows = getWorkflows(routeWorkspaceId) + const activeWorkflow = cachedWorkflows.find((w) => w.id === activeWorkflowId) + if (activeWorkflow?.isSandbox) { setIsExecuting(activeWorkflowId, true) return } // Get workspaceId from workflow metadata - const workspaceId = workflows[activeWorkflowId]?.workspaceId + const workspaceId = activeWorkflow?.workspaceId if (!workspaceId) { logger.error('Cannot execute workflow without workspaceId') @@ -748,7 +751,6 @@ export function useWorkflowExecution() { setExecutor, setPendingBlocks, setActiveBlocks, - workflows, queryClient, ] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 5000e67a9dc..11df2177d24 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -72,6 +72,7 @@ import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkspaceEnvironment } from '@/hooks/queries/environment' import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOAuthReturnForWorkflow } from '@/hooks/use-oauth-return' @@ -277,8 +278,9 @@ const WorkflowContent = React.memo( useOAuthReturnForWorkflow(workflowIdParam) + const { data: workflows = {} } = useWorkflowMap(workspaceId) + const { - workflows, activeWorkflowId, hydration, setActiveWorkflow, @@ -291,7 +293,6 @@ const WorkflowContent = React.memo( clearPendingSelection, } = useWorkflowRegistry( useShallow((state) => ({ - workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, hydration: state.hydration, setActiveWorkflow: state.setActiveWorkflow, @@ -2201,7 +2202,7 @@ const WorkflowContent = React.memo( const currentId = workflowIdParam const currentWorkspaceHydration = hydration.workspaceId - const isRegistryReady = hydration.phase !== 'metadata-loading' && hydration.phase !== 'idle' + const isRegistryReady = hydration.phase !== 'idle' // Wait for registry to be ready to prevent race conditions if ( @@ -2275,7 +2276,7 @@ const WorkflowContent = React.memo( if (embedded || sandbox) return // Wait for metadata to finish loading before making navigation decisions - if (hydration.phase === 'metadata-loading' || hydration.phase === 'idle') { + if (hydration.phase === 'idle') { return } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index f6183bbd661..2069c249160 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -13,8 +13,8 @@ import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { getBlock } from '@/blocks' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' +import { getWorkflows } from '@/hooks/queries/workflows' import { useVariablesStore } from '@/stores/panel/variables/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' /** Execution status for blocks in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' @@ -112,8 +112,8 @@ function resolveWorkflowName( if (subBlock?.type !== 'workflow-selector') return null if (!rawValue || typeof rawValue !== 'string') return null - const workflowMap = useWorkflowRegistry.getState().workflows - return workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL + const workflows = getWorkflows() + return workflows.find((w) => w.id === rawValue)?.name ?? DELETED_WORKFLOW_LABEL } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 1b5680c16e7..ba072e09c5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -27,10 +27,9 @@ import { useExportSelection, } from '@/app/workspace/[workspaceId]/w/hooks' import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders' -import { useCreateWorkflow } from '@/hooks/queries/workflows' +import { getWorkflows, useCreateWorkflow } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { FolderTreeNode } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' const logger = createLogger('FolderItem') @@ -246,7 +245,7 @@ export function FolderItem({ const isMixed = folderIds.length > 0 && workflowIds.length > 0 const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const names: string[] = [] for (const id of folderIds) { @@ -254,7 +253,7 @@ export function FolderItem({ if (f) names.push(f.name) } for (const id of workflowIds) { - const w = workflows[id] + const w = workflows.find((wf) => wf.id === id) if (w) names.push(w.name) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index c6f296f5626..bb231c9f14d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -25,6 +25,7 @@ import { useExportSelection, useExportWorkflow, } from '@/app/workspace/[workspaceId]/w/hooks' +import { getWorkflows, useUpdateWorkflow } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -60,7 +61,7 @@ export function WorkflowItem({ const params = useParams() const workspaceId = params.workspaceId as string const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const updateWorkflowMutation = useUpdateWorkflow() const userPermissions = useUserPermissionsContext() const isSelected = selectedWorkflows.has(workflow.id) @@ -166,9 +167,9 @@ export function WorkflowItem({ const handleColorChange = useCallback( (color: string) => { - updateWorkflow(workflow.id, { color }) + updateWorkflowMutation.mutate({ workspaceId, workflowId: workflow.id, metadata: { color } }) }, - [workflow.id, updateWorkflow] + [workflow.id, workspaceId] ) const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -227,12 +228,12 @@ export function WorkflowItem({ const folderIds = Array.from(finalFolderSelection) const isMixed = workflowIds.length > 0 && folderIds.length > 0 - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const { folders } = useFolderStore.getState() const names: string[] = [] for (const id of workflowIds) { - const w = workflows[id] + const w = workflows.find((wf) => wf.id === id) if (w) names.push(w.name) } for (const id of folderIds) { @@ -301,7 +302,11 @@ export function WorkflowItem({ } = useItemRename({ initialName: workflow.name, onSave: async (newName) => { - await updateWorkflow(workflow.id, { name: newName }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId: workflow.id, + metadata: { name: newName }, + }) }, itemType: 'workflow', itemId: workflow.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts index 4e86d19749e..cff8229eb6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts @@ -2,9 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' import { useReorderFolders } from '@/hooks/queries/folders' -import { useReorderWorkflows } from '@/hooks/queries/workflows' +import { getWorkflows, useReorderWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowList:DragDrop') @@ -234,7 +233,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { if (cached) return cached const currentFolders = useFolderStore.getState().folders - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = getWorkflows(workspaceId) const siblings = [ ...Object.values(currentFolders) .filter((f) => f.parentId === folderId) @@ -244,7 +243,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) { sortOrder: f.sortOrder, createdAt: f.createdAt, })), - ...Object.values(currentWorkflows) + ...currentWorkflows .filter((w) => w.folderId === folderId) .map((w) => ({ type: 'workflow' as const, @@ -307,13 +306,13 @@ export function useDragDrop(options: UseDragDropOptions = {}) { destinationFolderId: string | null ): { fromDestination: SiblingItem[]; fromOther: SiblingItem[] } => { const { folders } = useFolderStore.getState() - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceId) const fromDestination: SiblingItem[] = [] const fromOther: SiblingItem[] = [] for (const id of workflowIds) { - const workflow = workflows[id] + const workflow = workflows.find((w) => w.id === id) if (!workflow) continue const item: SiblingItem = { type: 'workflow', 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 e304f7d5975..c9533c049aa 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,11 +1,9 @@ import { useCallback, useMemo } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { useCreateWorkflow, useWorkflows } from '@/hooks/queries/workflows' +import { useCreateWorkflow, useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils' const logger = createLogger('useWorkflowOperations') @@ -16,18 +14,19 @@ interface UseWorkflowOperationsProps { export function useWorkflowOperations({ workspaceId }: UseWorkflowOperationsProps) { const router = useRouter() - const workflows = useWorkflowRegistry(useShallow((state) => state.workflows)) const workflowsQuery = useWorkflows(workspaceId) + const { data: workflowList = [] } = workflowsQuery + const { data: workflows = {} } = useWorkflowMap(workspaceId) const createWorkflowMutation = useCreateWorkflow() const regularWorkflows = useMemo( () => - Object.values(workflows) + workflowList .filter((workflow) => workflow.workspaceId === workspaceId) .sort((a, b) => { return b.createdAt.getTime() - a.createdAt.getTime() }), - [workflows, workspaceId] + [workflowList, workspaceId] ) const handleCreateWorkflow = useCallback(async (): Promise => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index 1190f0a390f..113da2a820b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -88,6 +88,7 @@ import { useRenameTask, useTasks, } from '@/hooks/queries/tasks' +import { useUpdateWorkflow } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' @@ -96,7 +97,6 @@ import { SIDEBAR_WIDTH } from '@/stores/constants' import { useFolderStore } from '@/stores/folders/store' import { useSearchModalStore } from '@/stores/modals/search/store' import { useSidebarStore } from '@/stores/sidebar/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('Sidebar') @@ -437,7 +437,7 @@ export const Sidebar = memo(function Sidebar() { useFolders(workspaceId) const folders = useFolderStore((s) => s.folders) const getFolderTree = useFolderStore((s) => s.getFolderTree) - const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow) + const updateWorkflowMutation = useUpdateWorkflow() const folderTree = useMemo( () => (isCollapsed && workspaceId ? getFolderTree(workspaceId) : []), @@ -813,7 +813,11 @@ export const Sidebar = memo(function Sidebar() { const workflowFlyoutRename = useFlyoutInlineRename({ itemType: 'workflow', onSave: async (workflowIdToRename, name) => { - await updateWorkflow(workflowIdToRename, { name }) + await updateWorkflowMutation.mutateAsync({ + workspaceId, + workflowId: workflowIdToRename, + metadata: { name }, + }) }, }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts index e109ca816cb..8d576c47d3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo } from 'react' +import { useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface UseCanDeleteProps { /** @@ -36,7 +36,7 @@ interface UseCanDeleteReturn { * @returns Functions to check deletion eligibility */ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { data: workflowList = [] } = useWorkflows(workspaceId) const folders = useFolderStore((s) => s.folders) /** @@ -44,9 +44,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe */ const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } = useMemo(() => { - const workspaceWorkflows = Object.values(workflows).filter( - (w) => w.workspaceId === workspaceId - ) + const workspaceWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const idSet = new Set(workspaceWorkflows.map((w) => w.id)) @@ -72,7 +70,7 @@ export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteRe workflowsByFolderId: byFolderId, childFoldersByParentId: childrenByParent, } - }, [workflows, folders, workspaceId]) + }, [workflowList, folders, workspaceId]) /** * Count workflows in a folder and all its subfolders recursively. diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts index b37cf32c322..bb22fb2fa01 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-selection.ts @@ -2,8 +2,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { useDeleteFolderMutation } from '@/hooks/queries/folders' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteSelection') @@ -46,8 +46,8 @@ export function useDeleteSelection({ onSuccess, }: UseDeleteSelectionProps) { const router = useRouter() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const deleteFolderMutation = useDeleteFolderMutation() const [isDeleting, setIsDeleting] = useState(false) @@ -72,7 +72,7 @@ export function useDeleteSelection({ ? workflowIds.some((id) => isActiveWorkflow(id)) : false - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) const workflowsInFolders = sidebarWorkflows .filter((w) => w.folderId && folderIds.includes(w.folderId)) @@ -128,7 +128,11 @@ export function useDeleteSelection({ } const standaloneWorkflowIds = workflowIds.filter((id) => !workflowsInFolders.includes(id)) - await Promise.all(standaloneWorkflowIds.map((id) => removeWorkflow(id))) + await Promise.all( + standaloneWorkflowIds.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection, clearFolderSelection } = useFolderStore.getState() clearSelection() @@ -151,12 +155,10 @@ export function useDeleteSelection({ workflowIds, folderIds, isDeleting, - workflows, + workflowList, workspaceId, isActiveWorkflow, router, - removeWorkflow, - deleteFolderMutation, onSuccess, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts index 37a56d24c0d..0e9c5d82cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-delete-workflow.ts @@ -1,10 +1,8 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' -import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' -import { workflowKeys } from '@/hooks/queries/workflows' +import { useDeleteWorkflowMutation, useWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDeleteWorkflow') @@ -41,9 +39,8 @@ export function useDeleteWorkflow({ onSuccess, }: UseDeleteWorkflowProps) { const router = useRouter() - const queryClient = useQueryClient() - const workflows = useWorkflowRegistry((s) => s.workflows) - const removeWorkflow = useWorkflowRegistry((s) => s.removeWorkflow) + const { data: workflowList = [] } = useWorkflows(workspaceId) + const deleteWorkflowMutation = useDeleteWorkflowMutation() const [isDeleting, setIsDeleting] = useState(false) /** @@ -65,7 +62,7 @@ export function useDeleteWorkflow({ const isActiveWorkflowBeingDeleted = typeof isActive === 'function' ? isActive(workflowIdsToDelete) : isActive - const sidebarWorkflows = Object.values(workflows).filter((w) => w.workspaceId === workspaceId) + const sidebarWorkflows = workflowList.filter((w) => w.workspaceId === workspaceId) let activeWorkflowId: string | null = null if (isActiveWorkflowBeingDeleted && typeof isActive === 'function') { @@ -105,8 +102,11 @@ export function useDeleteWorkflow({ } } - await Promise.all(workflowIdsToDelete.map((id) => removeWorkflow(id))) - await queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) + await Promise.all( + workflowIdsToDelete.map((id) => + deleteWorkflowMutation.mutateAsync({ workspaceId, workflowId: id }) + ) + ) const { clearSelection } = useFolderStore.getState() clearSelection() @@ -122,13 +122,12 @@ export function useDeleteWorkflow({ }, [ workflowIds, isDeleting, - workflows, + workflowList, workspaceId, isActive, router, - removeWorkflow, + deleteWorkflowMutation, onSuccess, - queryClient, ]) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts index 48a48146e07..ffb6d5a22d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-duplicate-selection.ts @@ -3,9 +3,8 @@ import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { useDuplicateFolderMutation } from '@/hooks/queries/folders' -import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +import { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateSelection') @@ -62,7 +61,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe setIsDuplicating(true) try { - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) const folderStore = useFolderStore.getState() const duplicatedWorkflowIds: string[] = [] @@ -97,7 +96,7 @@ export function useDuplicateSelection({ workspaceId, onSuccess }: UseDuplicateSe } for (const workflowId of workflowIds) { - const workflow = workflows[workflowId] + const workflow = workflows.find((w) => w.id === workflowId) if (!workflow) { logger.warn(`Workflow ${workflowId} not found, skipping`) continue 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 e1a14b49bfc..d0f793d2b0a 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 @@ -2,9 +2,8 @@ import { useCallback, useRef } from 'react' import { createLogger } from '@sim/logger' import { useRouter } from 'next/navigation' import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' +import { getWorkflows, useDuplicateWorkflowMutation } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useDuplicateWorkflow') @@ -61,10 +60,10 @@ export function useDuplicateWorkflow({ workspaceId, onSuccess }: UseDuplicateWor const duplicatedIds: string[] = [] try { - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) for (const sourceId of workflowIdsToDuplicate) { - const sourceWorkflow = workflows[sourceId] + const sourceWorkflow = workflows.find((w) => w.id === sourceId) if (!sourceWorkflow) { logger.warn(`Workflow ${sourceId} not found, skipping`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts index e7a646b2f68..8391bd9f1e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-folder.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportFolderToZip, @@ -8,9 +9,9 @@ import { sanitizePathSegment, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import { useWorkflowMap } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportFolder') @@ -89,7 +90,8 @@ function collectSubfolders( * Hook for managing folder export to ZIP. */ export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) { - const workflows = useWorkflowRegistry((s) => s.workflows) + const { workspaceId } = useParams<{ workspaceId: string }>() + const { data: workflows = {} } = useWorkflowMap(workspaceId) const folders = useFolderStore((s) => s.folders) const [isExporting, setIsExporting] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts index 92502caf55f..a240372b4d5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-selection.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportWorkflowsToZip, @@ -7,9 +8,9 @@ import { fetchWorkflowForExport, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import { getWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' const logger = createLogger('useExportSelection') @@ -88,10 +89,15 @@ function collectSubfoldersForMultipleFolders( */ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) { const [isExporting, setIsExporting] = useState(false) + const params = useParams() + const workspaceId = params.workspaceId as string | undefined const onSuccessRef = useRef(onSuccess) onSuccessRef.current = onSuccess + const workspaceIdRef = useRef(workspaceId) + workspaceIdRef.current = workspaceId + /** * Export all selected workflows and folders to a ZIP file. * - Collects workflows from selected folders recursively @@ -113,7 +119,8 @@ export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) setIsExporting(true) try { - const { workflows } = useWorkflowRegistry.getState() + const workflowsArray = getWorkflows(workspaceIdRef.current) + const workflows = Object.fromEntries(workflowsArray.map((w) => [w.id, w])) const { folders } = useFolderStore.getState() const workflowsFromFolders: CollectedWorkflow[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index afa812ed549..9b4a5a628a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -1,5 +1,6 @@ import { useCallback, useRef, useState } from 'react' import { createLogger } from '@sim/logger' +import { useParams } from 'next/navigation' import { downloadFile, exportWorkflowsToZip, @@ -7,8 +8,8 @@ import { fetchWorkflowForExport, sanitizePathSegment, } from '@/lib/workflows/operations/import-export' +import { getWorkflows } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('useExportWorkflow') @@ -24,10 +25,15 @@ interface UseExportWorkflowProps { */ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { const [isExporting, setIsExporting] = useState(false) + const params = useParams() + const workspaceId = params.workspaceId as string | undefined const onSuccessRef = useRef(onSuccess) onSuccessRef.current = onSuccess + const workspaceIdRef = useRef(workspaceId) + workspaceIdRef.current = workspaceId + /** * Export the workflow(s) to JSON or ZIP * - Single workflow: exports as JSON file @@ -52,11 +58,11 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) { count: workflowIdsToExport.length, }) - const { workflows } = useWorkflowRegistry.getState() + const workflows = getWorkflows(workspaceIdRef.current) const exportedWorkflows = [] for (const workflowId of workflowIdsToExport) { - const workflowMeta = workflows[workflowId] + const workflowMeta = workflows.find((w) => w.id === workflowId) if (!workflowMeta) { logger.warn(`Workflow ${workflowId} not found in registry`) continue diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index e19bfd387e4..0c2802d8e63 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -12,26 +12,19 @@ const logger = createLogger('WorkflowsPage') export default function WorkflowsPage() { const router = useRouter() - const workflows = useWorkflowRegistry((s) => s.workflows) const setActiveWorkflow = useWorkflowRegistry((s) => s.setActiveWorkflow) const params = useParams() const workspaceId = params.workspaceId as string const [isMounted, setIsMounted] = useState(false) - // Fetch workflows using React Query - const { isLoading, isError } = useWorkflows(workspaceId) + const { data: workflows = [], isLoading, isError } = useWorkflows(workspaceId) - // Track when component is mounted to avoid hydration issues useEffect(() => { setIsMounted(true) }, []) - // Handle redirection once workflows are loaded and component is mounted useEffect(() => { - // Wait for component to be mounted to avoid hydration mismatches if (!isMounted) return - - // Only proceed if workflows are done loading if (isLoading) return if (isError) { @@ -39,18 +32,10 @@ export default function WorkflowsPage() { return } - const workflowIds = Object.keys(workflows) - - // Validate that workflows belong to the current workspace - const workspaceWorkflows = workflowIds.filter((id) => { - const workflow = workflows[id] - return workflow.workspaceId === workspaceId - }) + const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId) - // If we have valid workspace workflows, redirect to the first one if (workspaceWorkflows.length > 0) { - const firstWorkflowId = workspaceWorkflows[0] - router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) + router.replace(`/workspace/${workspaceId}/w/${workspaceWorkflows[0].id}`) } }, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError]) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 50db926be7d..4e92948ee17 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -21,7 +21,6 @@ import { parseJSON } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('WorkflowBlockHandler') @@ -74,10 +73,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error('No workflow selected for execution') } - // Initialize with registry name, will be updated with loaded workflow name - const { workflows } = useWorkflowRegistry.getState() - const workflowMetadata = workflows[workflowId] - let childWorkflowName = workflowMetadata?.name || workflowId + let childWorkflowName = workflowId // Unique ID per invocation — used to correlate child block events with this specific // workflow block execution, preventing cross-iteration child mixing in loop contexts. @@ -111,8 +107,7 @@ export class WorkflowBlockHandler implements BlockHandler { throw new Error(`Child workflow ${workflowId} not found`) } - // Update with loaded workflow name (more reliable than registry) - childWorkflowName = workflowMetadata?.name || childWorkflow.name || 'Unknown Workflow' + childWorkflowName = childWorkflow.name || 'Unknown Workflow' logger.info( `Executing child workflow: ${childWorkflowName} (${workflowId}), call chain depth ${ctx.callChain?.length || 0}` diff --git a/apps/sim/hooks/queries/custom-tools.ts b/apps/sim/hooks/queries/custom-tools.ts index adb8af7183b..f99cbe22eeb 100644 --- a/apps/sim/hooks/queries/custom-tools.ts +++ b/apps/sim/hooks/queries/custom-tools.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getQueryClient } from '@/app/_shell/providers/query-provider' +import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' const logger = createLogger('CustomToolsQueries') const API_ENDPOINT = '/api/tools/custom' @@ -91,12 +92,6 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo * Extract workspaceId from the current URL path * Expected format: /workspace/{workspaceId}/... */ -function getWorkspaceIdFromUrl(): string | null { - if (typeof window === 'undefined') return null - const match = window.location.pathname.match(/^\/workspace\/([^/]+)/) - return match?.[1] ?? null -} - /** * Get all custom tools from the query cache (for non-React code) * If workspaceId is not provided, extracts it from the current URL diff --git a/apps/sim/hooks/queries/folders.ts b/apps/sim/hooks/queries/folders.ts index 5b22872feec..8f125fb7c74 100644 --- a/apps/sim/hooks/queries/folders.ts +++ b/apps/sim/hooks/queries/folders.ts @@ -6,10 +6,9 @@ import { generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' -import { workflowKeys } from '@/hooks/queries/workflows' +import { getWorkflows, workflowKeys } from '@/hooks/queries/workflows' import { useFolderStore } from '@/stores/folders/store' import type { WorkflowFolder } from '@/stores/folders/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('FolderQueries') @@ -169,7 +168,9 @@ export function useCreateFolder() { queryClient, 'CreateFolder', (variables, tempId, previousFolders) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) return { id: tempId, @@ -267,7 +268,9 @@ export function useDuplicateFolderMutation() { queryClient, 'DuplicateFolder', (variables, tempId, previousFolders) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) const sourceFolder = previousFolders[variables.id] const targetParentId = variables.parentId ?? sourceFolder?.parentId ?? null diff --git a/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts b/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts new file mode 100644 index 00000000000..039584ba4d9 --- /dev/null +++ b/apps/sim/hooks/queries/utils/get-workspace-id-from-url.ts @@ -0,0 +1,10 @@ +/** + * Extracts the workspace ID from the current URL pathname. + * Returns `null` on the server or when the URL doesn't match `/workspace/{id}/...`. + * Used as a fallback for synchronous cache-read helpers that can't access React hooks. + */ +export function getWorkspaceIdFromUrl(): string | null { + if (typeof window === 'undefined') return null + const match = window.location.pathname.match(/^\/workspace\/([^/]+)/) + return match?.[1] ?? null +} diff --git a/apps/sim/hooks/queries/utils/workflow-keys.ts b/apps/sim/hooks/queries/utils/workflow-keys.ts new file mode 100644 index 00000000000..8512e02a42b --- /dev/null +++ b/apps/sim/hooks/queries/utils/workflow-keys.ts @@ -0,0 +1,13 @@ +export type WorkflowQueryScope = 'active' | 'archived' | 'all' + +export const workflowKeys = { + all: ['workflows'] as const, + lists: () => [...workflowKeys.all, 'list'] as const, + list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => + [...workflowKeys.lists(), workspaceId ?? '', scope] as const, + deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, + deploymentVersion: (workflowId: string | undefined, version: number | undefined) => + [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, + state: (workflowId: string | undefined) => + [...workflowKeys.all, 'state', workflowId ?? ''] as const, +} diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index c2ae3a40363..b398a8f38c4 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -1,14 +1,16 @@ -import { useEffect } from 'react' +/** + * React Query hooks for managing workflow metadata and mutations. + */ + import { createLogger } from '@sim/logger' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { deploymentKeys } from '@/hooks/queries/deployments' -import { - createOptimisticMutationHandlers, - generateTempId, -} from '@/hooks/queries/utils/optimistic-mutation' +import { getWorkspaceIdFromUrl } from '@/hooks/queries/utils/get-workspace-id-from-url' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -18,24 +20,23 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowQueries') -type WorkflowQueryScope = 'active' | 'archived' | 'all' - -export const workflowKeys = { - all: ['workflows'] as const, - lists: () => [...workflowKeys.all, 'list'] as const, - list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => - [...workflowKeys.lists(), workspaceId ?? '', scope] as const, - deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, - deploymentVersion: (workflowId: string | undefined, version: number | undefined) => - [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, - state: (workflowId: string | undefined) => - [...workflowKeys.all, 'state', workflowId ?? ''] as const, -} +export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys' /** - * Fetches workflow state from the API. - * Used as the base query for both state preview and input fields extraction. + * Reads the workflow list from the React Query cache synchronously. + * For use in non-React code (stores, event handlers, utilities). + * Falls back to the URL workspace when `workspaceId` is omitted. */ +export function getWorkflows( + workspaceId?: string, + scope: WorkflowQueryScope = 'active' +): WorkflowMetadata[] { + if (typeof window === 'undefined') return [] + const wsId = workspaceId ?? getWorkspaceIdFromUrl() + if (!wsId) return [] + return getQueryClient().getQueryData(workflowKeys.list(wsId, scope)) ?? [] +} + async function fetchWorkflowState( workflowId: string, signal?: AbortSignal @@ -47,31 +48,40 @@ async function fetchWorkflowState( } /** - * Hook to fetch workflow state. + * Fetches the full workflow state for a single workflow. * Used by workflow blocks to show a preview of the child workflow * and as a base query for input fields extraction. - * - * @param workflowId - The workflow ID to fetch state for - * @returns Query result with workflow state */ export function useWorkflowState(workflowId: string | undefined) { return useQuery({ queryKey: workflowKeys.state(workflowId), queryFn: ({ signal }) => fetchWorkflowState(workflowId!, signal), enabled: Boolean(workflowId), - staleTime: 30 * 1000, // 30 seconds - placeholderData: keepPreviousData, + staleTime: 30 * 1000, }) } -function mapWorkflow(workflow: any): WorkflowMetadata { +interface WorkflowApiRow { + id: string + name: string + description?: string | null + color: string + workspaceId: string + folderId?: string | null + sortOrder?: number | null + createdAt: string + updatedAt?: string | null + archivedAt?: string | null +} + +function mapWorkflow(workflow: WorkflowApiRow): WorkflowMetadata { return { id: workflow.id, name: workflow.name, - description: workflow.description, + description: workflow.description ?? undefined, color: workflow.color, workspaceId: workflow.workspaceId, - folderId: workflow.folderId, + folderId: workflow.folderId ?? undefined, sortOrder: workflow.sortOrder ?? 0, createdAt: new Date(workflow.createdAt), lastModified: new Date(workflow.updatedAt || workflow.createdAt), @@ -92,68 +102,38 @@ async function fetchWorkflows( throw new Error('Failed to fetch workflows') } - const { data }: { data: any[] } = await response.json() + const { data }: { data: WorkflowApiRow[] } = await response.json() return data.map(mapWorkflow) } -export function useWorkflows( - workspaceId?: string, - options?: { syncRegistry?: boolean; scope?: WorkflowQueryScope } -) { - const { syncRegistry = true, scope = 'active' } = options || {} - const beginMetadataLoad = useWorkflowRegistry((state) => state.beginMetadataLoad) - const completeMetadataLoad = useWorkflowRegistry((state) => state.completeMetadataLoad) - const failMetadataLoad = useWorkflowRegistry((state) => state.failMetadataLoad) - - const query = useQuery({ +export function useWorkflows(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { + const { scope = 'active' } = options || {} + + return useQuery({ queryKey: workflowKeys.list(workspaceId, scope), queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), enabled: Boolean(workspaceId), placeholderData: keepPreviousData, staleTime: 60 * 1000, }) +} + +/** + * Returns workflows as a `Record` keyed by ID. + * Uses the `select` option so the transformation runs inside React Query + * with structural sharing — components only re-render when the record changes. + */ +export function useWorkflowMap(workspaceId?: string, options?: { scope?: WorkflowQueryScope }) { + const { scope = 'active' } = options || {} - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - (query.status === 'pending' || query.isPlaceholderData) - ) { - beginMetadataLoad(workspaceId) - } - }, [syncRegistry, scope, workspaceId, query.status, query.isPlaceholderData, beginMetadataLoad]) - - useEffect(() => { - if ( - syncRegistry && - scope === 'active' && - workspaceId && - query.status === 'success' && - query.data && - !query.isPlaceholderData - ) { - completeMetadataLoad(workspaceId, query.data) - } - }, [ - syncRegistry, - scope, - workspaceId, - query.status, - query.data, - query.isPlaceholderData, - completeMetadataLoad, - ]) - - useEffect(() => { - if (syncRegistry && scope === 'active' && workspaceId && query.status === 'error') { - const message = - query.error instanceof Error ? query.error.message : 'Failed to fetch workflows' - failMetadataLoad(workspaceId, message) - } - }, [syncRegistry, scope, workspaceId, query.status, query.error, failMetadataLoad]) - - return query + return useQuery({ + queryKey: workflowKeys.list(workspaceId, scope), + queryFn: ({ signal }) => fetchWorkflows(workspaceId as string, scope, signal), + enabled: Boolean(workspaceId), + placeholderData: keepPreviousData, + staleTime: 60 * 1000, + select: (data) => Object.fromEntries(data.map((w) => [w.id, w])), + }) } interface CreateWorkflowVariables { @@ -177,128 +157,9 @@ interface CreateWorkflowResult { sortOrder: number } -interface DuplicateWorkflowVariables { - workspaceId: string - sourceId: string - name: string - description?: string - color: string - folderId?: string | null - newId?: string -} - -interface DuplicateWorkflowResult { - id: string - name: string - description?: string - color: string - workspaceId: string - folderId?: string | null - sortOrder: number - 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, - customGenerateTempId?: (variables: TVariables) => string -) { - return createOptimisticMutationHandlers< - CreateWorkflowResult | DuplicateWorkflowResult, - TVariables, - WorkflowMetadata - >(queryClient, { - name, - getQueryKey: (variables) => workflowKeys.list(variables.workspaceId, 'active'), - getSnapshot: () => ({ ...useWorkflowRegistry.getState().workflows }), - generateTempId: customGenerateTempId ?? (() => 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, - sortOrder: 'sortOrder' in data ? data.sortOrder : 0, - }, - }, - error: null, - } - }) - - if (tempId !== data.id) { - useFolderStore.setState((state) => { - const selectedWorkflows = new Set(state.selectedWorkflows) - if (selectedWorkflows.has(tempId)) { - selectedWorkflows.delete(tempId) - selectedWorkflows.add(data.id) - } - return { selectedWorkflows } - }) - } - }, - rollback: (snapshot) => { - useWorkflowRegistry.setState({ workflows: snapshot }) - }, - }) -} - export function useCreateWorkflow() { const queryClient = useQueryClient() - const handlers = createWorkflowMutationHandlers( - queryClient, - 'CreateWorkflow', - (variables, tempId) => { - let sortOrder: number - if (variables.sortOrder !== undefined) { - sortOrder = variables.sortOrder - } else { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - sortOrder = getTopInsertionSortOrder( - currentWorkflows, - currentFolders, - variables.workspaceId, - variables.folderId - ) - } - - return { - 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, - sortOrder, - } - }, - (variables) => variables.id ?? crypto.randomUUID() - ) - return useMutation({ mutationFn: async (variables: CreateWorkflowVariables): Promise => { const { workspaceId, name, description, color, folderId, sortOrder, id, deduplicate } = @@ -358,9 +219,86 @@ export function useCreateWorkflow() { sortOrder: createdWorkflow.sortOrder ?? 0, } }, - ...handlers, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + const tempId = variables.id ?? crypto.randomUUID() + let sortOrder: number + if (variables.sortOrder !== undefined) { + sortOrder = variables.sortOrder + } else { + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) + const currentFolders = useFolderStore.getState().folders + sortOrder = getTopInsertionSortOrder( + currentWorkflows, + currentFolders, + variables.workspaceId, + variables.folderId + ) + } + + const optimistic: WorkflowMetadata = { + 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, + sortOrder, + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[CreateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } const { subBlockValues } = buildDefaultWorkflowArtifacts() useSubBlockStore.setState((state) => ({ @@ -369,40 +307,51 @@ export function useCreateWorkflow() { [data.id]: subBlockValues, }, })) + + logger.info(`[CreateWorkflow] Success, replaced temp entry ${tempId}`) + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + logger.info('[CreateWorkflow] Rolled back to previous state') + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) }, }) } -export function useDuplicateWorkflowMutation() { - const queryClient = useQueryClient() +interface DuplicateWorkflowVariables { + workspaceId: string + sourceId: string + name: string + description?: string + color: string + folderId?: string | null + newId?: string +} - const handlers = createWorkflowMutationHandlers( - queryClient, - 'DuplicateWorkflow', - (variables, tempId) => { - const currentWorkflows = useWorkflowRegistry.getState().workflows - const currentFolders = useFolderStore.getState().folders - const targetFolderId = variables.folderId ?? null +interface DuplicateWorkflowResult { + id: string + name: string + description?: string + color: string + workspaceId: string + folderId?: string | null + sortOrder: number + blocksCount: number + edgesCount: number + subflowsCount: number +} - return { - id: tempId, - name: variables.name, - lastModified: new Date(), - createdAt: new Date(), - description: variables.description, - color: variables.color, - workspaceId: variables.workspaceId, - folderId: targetFolderId, - sortOrder: getTopInsertionSortOrder( - currentWorkflows, - currentFolders, - variables.workspaceId, - targetFolderId - ), - } - }, - (variables) => variables.newId ?? crypto.randomUUID() - ) +export function useDuplicateWorkflowMutation() { + const queryClient = useQueryClient() return useMutation({ mutationFn: async (variables: DuplicateWorkflowVariables): Promise => { @@ -449,11 +398,82 @@ export function useDuplicateWorkflowMutation() { subflowsCount: duplicatedWorkflow.subflowsCount || 0, } }, - ...handlers, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + const tempId = variables.newId ?? crypto.randomUUID() + + const currentWorkflows = Object.fromEntries( + getWorkflows(variables.workspaceId).map((w) => [w.id, w]) + ) + const currentFolders = useFolderStore.getState().folders + const targetFolderId = variables.folderId ?? null + + const optimistic: WorkflowMetadata = { + id: tempId, + name: variables.name, + lastModified: new Date(), + createdAt: new Date(), + description: variables.description, + color: variables.color, + workspaceId: variables.workspaceId, + folderId: targetFolderId, + sortOrder: getTopInsertionSortOrder( + currentWorkflows, + currentFolders, + variables.workspaceId, + targetFolderId + ), + } + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => [...(old ?? []), optimistic] + ) + logger.info(`[DuplicateWorkflow] Added optimistic entry: ${tempId}`) + + return { snapshot, tempId } + }, onSuccess: (data, variables, context) => { - handlers.onSuccess(data, variables, context) + if (!context) return + const { tempId } = context + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === tempId + ? { + id: data.id, + name: data.name, + lastModified: new Date(), + createdAt: new Date(), + description: data.description, + color: data.color, + workspaceId: data.workspaceId, + folderId: data.folderId, + sortOrder: data.sortOrder, + } + : w + ) + ) + + if (tempId !== data.id) { + useFolderStore.setState((state) => { + const selectedWorkflows = new Set(state.selectedWorkflows) + if (selectedWorkflows.has(tempId)) { + selectedWorkflows.delete(tempId) + selectedWorkflows.add(data.id) + } + return { selectedWorkflows } + }) + } - // Copy subblock values from source if it's the active workflow const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (variables.sourceId === activeWorkflowId) { const sourceSubblockValues = @@ -465,6 +485,137 @@ export function useDuplicateWorkflowMutation() { }, })) } + + logger.info(`[DuplicateWorkflow] Success, replaced temp entry ${tempId}`) + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + logger.info('[DuplicateWorkflow] Rolled back to previous state') + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + }, + }) +} + +interface UpdateWorkflowVariables { + workspaceId: string + workflowId: string + metadata: Partial +} + +export function useUpdateWorkflow() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables: UpdateWorkflowVariables) => { + const response = await fetch(`/api/workflows/${variables.workflowId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(variables.metadata), + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Failed to update workflow') + } + + const { workflow: updatedWorkflow } = await response.json() + return mapWorkflow(updatedWorkflow) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => + w.id === variables.workflowId + ? { ...w, ...variables.metadata, lastModified: new Date() } + : w + ) + ) + + return { snapshot } + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + }, + }) +} + +interface DeleteWorkflowVariables { + workspaceId: string + workflowId: string +} + +export function useDeleteWorkflowMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (variables: DeleteWorkflowVariables) => { + const response = await fetch(`/api/workflows/${variables.workflowId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Unknown error' })) + throw new Error(error.error || 'Failed to delete workflow') + } + + logger.info(`Successfully deleted workflow ${variables.workflowId} from database`) + }, + onMutate: async (variables) => { + await queryClient.cancelQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) + + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => (old ?? []).filter((w) => w.id !== variables.workflowId) + ) + + return { snapshot } + }, + onError: (_error, variables, context) => { + if (context?.snapshot) { + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) + } + }, + onSettled: (_data, _error, variables) => { + queryClient.invalidateQueries({ + queryKey: workflowKeys.list(variables.workspaceId, 'active'), + }) }, }) } @@ -496,17 +647,13 @@ export async function fetchDeploymentVersionState( return data.deployedState } -/** - * Hook for fetching the workflow state of a specific deployment version. - * Used in the deploy modal to preview historical versions. - */ export function useDeploymentVersionState(workflowId: string | null, version: number | null) { return useQuery({ queryKey: workflowKeys.deploymentVersion(workflowId ?? undefined, version ?? undefined), queryFn: ({ signal }) => fetchDeploymentVersionState(workflowId as string, version as number, signal), enabled: Boolean(workflowId) && version !== null, - staleTime: 5 * 60 * 1000, // 5 minutes - deployment versions don't change + staleTime: 5 * 60 * 1000, }) } @@ -515,9 +662,6 @@ interface RevertToVersionVariables { version: number } -/** - * Mutation hook for reverting (loading) a deployment version into the current workflow. - */ export function useRevertToVersion() { const queryClient = useQueryClient() @@ -531,7 +675,7 @@ export function useRevertToVersion() { throw new Error('Failed to load deployment') } }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workflowKeys.state(variables.workflowId), }) @@ -576,28 +720,33 @@ export function useReorderWorkflows() { onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: workflowKeys.lists() }) - const snapshot = { ...useWorkflowRegistry.getState().workflows } - - useWorkflowRegistry.setState((state) => { - const updated = { ...state.workflows } - for (const update of variables.updates) { - if (updated[update.id]) { - updated[update.id] = { - ...updated[update.id], + const snapshot = queryClient.getQueryData( + workflowKeys.list(variables.workspaceId, 'active') + ) + + const updateMap = new Map(variables.updates.map((u) => [u.id, u])) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + (old) => + (old ?? []).map((w) => { + const update = updateMap.get(w.id) + if (!update) return w + return { + ...w, sortOrder: update.sortOrder, - folderId: - update.folderId !== undefined ? update.folderId : updated[update.id].folderId, + folderId: update.folderId !== undefined ? update.folderId : w.folderId, } - } - } - return { workflows: updated } - }) + }) + ) return { snapshot } }, - onError: (_error, _variables, context) => { + onError: (_error, variables, context) => { if (context?.snapshot) { - useWorkflowRegistry.setState({ workflows: context.snapshot }) + queryClient.setQueryData( + workflowKeys.list(variables.workspaceId, 'active'), + context.snapshot + ) } }, onSettled: (_data, _error, variables) => { @@ -606,9 +755,6 @@ export function useReorderWorkflows() { }) } -/** - * Import workflow mutation (superuser debug) - */ interface ImportWorkflowParams { workflowId: string targetWorkspaceId: string @@ -641,7 +787,7 @@ export function useImportWorkflow() { return data }, - onSuccess: (_data, variables) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: workflowKeys.lists() }) }, }) diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index fd050f97a6a..7b4d433db7a 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -1,3 +1,4 @@ +import { getWorkflows } from '@/hooks/queries/workflows' import { fetchJson, fetchOAuthToken } from '@/hooks/selectors/helpers' import type { SelectorContext, @@ -6,7 +7,6 @@ import type { SelectorOption, SelectorQueryArgs, } from '@/hooks/selectors/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const SELECTOR_STALE = 60 * 1000 @@ -1693,19 +1693,19 @@ const registry: Record = { ], enabled: () => true, fetchList: async ({ context }: SelectorQueryArgs): Promise => { - const { workflows } = useWorkflowRegistry.getState() - return Object.entries(workflows) - .filter(([id]) => id !== context.excludeWorkflowId) - .map(([id, workflow]) => ({ - id, - label: workflow.name || `Workflow ${id.slice(0, 8)}`, + const workflows = getWorkflows() + return workflows + .filter((w) => w.id !== context.excludeWorkflowId) + .map((w) => ({ + id: w.id, + label: w.name || `Workflow ${w.id.slice(0, 8)}`, })) .sort((a, b) => a.label.localeCompare(b.label)) }, fetchById: async ({ detailId }: SelectorQueryArgs): Promise => { if (!detailId) return null - const { workflows } = useWorkflowRegistry.getState() - const workflow = workflows[detailId] + const workflows = getWorkflows() + const workflow = workflows.find((w) => w.id === detailId) if (!workflow) return null return { id: detailId, diff --git a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts index 617626700d7..35d90f1189d 100644 --- a/apps/sim/lib/copilot/tools/client/tool-display-registry.ts +++ b/apps/sim/lib/copilot/tools/client/tool-display-registry.ts @@ -41,6 +41,7 @@ import { Zap, } from 'lucide-react' import { getCustomTool } from '@/hooks/queries/custom-tools' +import { getWorkflows } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -1630,7 +1631,7 @@ const META_run_workflow: ToolMetadata = { getDynamicText: (params, state) => { const workflowId = params?.workflowId || useWorkflowRegistry.getState().activeWorkflowId if (workflowId) { - const workflowName = useWorkflowRegistry.getState().workflows[workflowId]?.name + const workflowName = getWorkflows().find((w) => w.id === workflowId)?.name if (workflowName) { switch (state) { case ClientToolCallState.success: diff --git a/apps/sim/lib/core/utils/optimistic-update.ts b/apps/sim/lib/core/utils/optimistic-update.ts deleted file mode 100644 index 4759255e4db..00000000000 --- a/apps/sim/lib/core/utils/optimistic-update.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createLogger } from '@sim/logger' - -const logger = createLogger('OptimisticUpdate') - -/** - * Options for performing an optimistic update with automatic rollback on error - */ -export interface OptimisticUpdateOptions { - /** - * Function that returns the current state value (for rollback purposes) - */ - getCurrentState: () => T - /** - * Function that performs the optimistic update to the UI state - */ - optimisticUpdate: () => void - /** - * Async function that performs the actual API call - */ - apiCall: () => Promise - /** - * Function that rolls back the state to the original value - * @param originalValue - The value returned by getCurrentState before the update - */ - rollback: (originalValue: T) => void - /** - * Optional error message to log if the operation fails - */ - errorMessage?: string - /** - * Optional callback to execute on error (e.g., show toast notification) - */ - onError?: (error: Error, originalValue: T) => void - /** - * Optional callback that always runs regardless of success or error (e.g., to clear loading states) - */ - onComplete?: () => void -} - -/** - * Performs an optimistic update with automatic rollback on error. - * This utility standardizes the pattern of: - * 1. Save current state - * 2. Update UI optimistically - * 3. Make API call - * 4. Rollback on error - * - * @example - * ```typescript - * await withOptimisticUpdate({ - * getCurrentState: () => get().folders[id], - * optimisticUpdate: () => set(state => ({ - * folders: { ...state.folders, [id]: { ...folder, name: newName } } - * })), - * apiCall: async () => { - * await fetch(`/api/folders/${id}`, { - * method: 'PUT', - * body: JSON.stringify({ name: newName }) - * }) - * }, - * rollback: (originalFolder) => set(state => ({ - * folders: { ...state.folders, [id]: originalFolder } - * })), - * errorMessage: 'Failed to rename folder', - * onError: (error) => toast.error('Could not rename folder') - * }) - * ``` - */ -export async function withOptimisticUpdate(options: OptimisticUpdateOptions): Promise { - const { - getCurrentState, - optimisticUpdate, - apiCall, - rollback, - errorMessage, - onError, - onComplete, - } = options - - const originalValue = getCurrentState() - - optimisticUpdate() - - try { - await apiCall() - } catch (error) { - rollback(originalValue) - - if (errorMessage) { - logger.error(errorMessage, { error }) - } - - if (onError && error instanceof Error) { - onError(error, originalValue) - } - - throw error - } finally { - if (onComplete) { - onComplete() - } - } -} diff --git a/apps/sim/stores/index.ts b/apps/sim/stores/index.ts index 4ae3c335f07..d1bbbc9b227 100644 --- a/apps/sim/stores/index.ts +++ b/apps/sim/stores/index.ts @@ -201,7 +201,6 @@ export { export const resetAllStores = () => { // Reset all stores to initial state useWorkflowRegistry.setState({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index c3cc04ec6f8..9f223ba9310 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getWorkflows } from '@/hooks/queries/workflows' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -13,11 +14,13 @@ const logger = createLogger('Workflows') * @returns The workflow with merged state values or null if not found/not active */ export function getWorkflowWithValues(workflowId: string) { - const { workflows } = useWorkflowRegistry.getState() + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined + const workflows = getWorkflows(workspaceId) const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() - if (!workflows[workflowId]) { + const metadata = workflows.find((w) => w.id === workflowId) + if (!metadata) { logger.warn(`Workflow ${workflowId} not found`) return null } @@ -28,8 +31,6 @@ export function getWorkflowWithValues(workflowId: string) { return null } - const metadata = workflows[workflowId] - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry.getState().getWorkflowDeploymentStatus(workflowId) @@ -80,14 +81,18 @@ export function getBlockWithValues(blockId: string): BlockState | null { * @returns An object containing workflows, with state only for the active workflow */ export function getAllWorkflowsWithValues() { - const { workflows } = useWorkflowRegistry.getState() + const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId ?? undefined + const workflows = getWorkflows(workspaceId) const result: Record = {} const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const currentState = useWorkflowStore.getState() // Only sync the active workflow to ensure we always send valid state data - if (activeWorkflowId && workflows[activeWorkflowId]) { - const metadata = workflows[activeWorkflowId] + const activeMetadata = activeWorkflowId + ? workflows.find((w) => w.id === activeWorkflowId) + : undefined + if (activeWorkflowId && activeMetadata) { + const metadata = activeMetadata // Get deployment status from registry const deploymentStatus = useWorkflowRegistry diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index dca49b8ddae..0f68a18c23f 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -1,10 +1,9 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' -import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' -import { getNextWorkflowColor } from '@/lib/workflows/colors' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { workflowKeys } from '@/hooks/queries/utils/workflow-keys' import { useVariablesStore } from '@/stores/panel/variables/store' import type { DeploymentStatus, @@ -28,13 +27,10 @@ const initialHydration: HydrationState = { const createRequestId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}` -// Track workspace transitions to prevent race conditions let isWorkspaceTransitioning = false -const TRANSITION_TIMEOUT = 5000 // 5 seconds maximum for workspace transitions +const TRANSITION_TIMEOUT = 5000 -// Resets workflow and subblock stores to prevent data leakage between workspaces function resetWorkflowStores() { - // Reset the workflow store to prevent data leakage between workspaces useWorkflowStore.setState({ blocks: {}, edges: [], @@ -44,16 +40,11 @@ function resetWorkflowStores() { lastSaved: Date.now(), }) - // Reset the subblock store useSubBlockStore.setState({ workflowValues: {}, }) } -/** - * Handles workspace transition state tracking - * @param isTransitioning Whether workspace is currently transitioning - */ function setWorkspaceTransitioning(isTransitioning: boolean): void { isWorkspaceTransitioning = isTransitioning @@ -70,7 +61,6 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void { export const useWorkflowRegistry = create()( devtools( (set, get) => ({ - workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, @@ -78,61 +68,6 @@ export const useWorkflowRegistry = create()( clipboard: null, pendingSelection: null, - beginMetadataLoad: (workspaceId: string) => { - set((state) => ({ - error: null, - hydration: { - phase: 'metadata-loading', - workspaceId, - workflowId: null, - requestId: null, - error: null, - }, - })) - }, - - completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => { - const mapped = workflows.reduce>((acc, workflow) => { - acc[workflow.id] = workflow - return acc - }, {}) - - set((state) => { - const shouldPreserveHydration = - state.hydration.phase === 'state-loading' || - (state.hydration.phase === 'ready' && - state.hydration.workflowId && - mapped[state.hydration.workflowId]) - - return { - workflows: mapped, - error: null, - hydration: shouldPreserveHydration - ? state.hydration - : { - phase: 'metadata-ready', - workspaceId, - workflowId: null, - requestId: null, - error: null, - }, - } - }) - }, - - failMetadataLoad: (workspaceId: string | null, errorMessage: string) => { - set((state) => ({ - error: errorMessage, - hydration: { - phase: 'error', - workspaceId: workspaceId ?? state.hydration.workspaceId, - workflowId: state.hydration.workflowId, - requestId: null, - error: errorMessage, - }, - })) - }, - switchToWorkspace: async (workspaceId: string) => { if (isWorkspaceTransitioning) { logger.warn( @@ -148,13 +83,15 @@ export const useWorkflowRegistry = create()( resetWorkflowStores() + // Invalidate the old workspace workflow cache so a fresh fetch happens + getQueryClient().invalidateQueries({ queryKey: workflowKeys.lists() }) + set({ activeWorkflowId: null, - workflows: {}, deploymentStatuses: {}, error: null, hydration: { - phase: 'metadata-loading', + phase: 'idle', workspaceId, workflowId: null, requestId: null, @@ -250,9 +187,15 @@ export const useWorkflowRegistry = create()( }, loadWorkflowState: async (workflowId: string) => { - const { workflows } = get() - - if (!workflows[workflowId]) { + // Check if the workflow exists in the React Query cache + const workspaceId = get().hydration.workspaceId + const workflows = workspaceId + ? (getQueryClient().getQueryData( + workflowKeys.list(workspaceId, 'active') + ) ?? []) + : [] + + if (!workflows.find((w) => w.id === workflowId)) { const message = `Workflow not found: ${workflowId}` logger.error(message) set({ error: message }) @@ -392,10 +335,6 @@ export const useWorkflowRegistry = create()( const workflowStoreState = useWorkflowStore.getState() const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0 - // Skip loading only if: - // - Same workflow is already active - // - Workflow data exists - // - Hydration is complete (phase is 'ready') const isFullyHydrated = activeWorkflowId === id && hasWorkflowData && @@ -410,320 +349,16 @@ export const useWorkflowRegistry = create()( await get().loadWorkflowState(id) }, - /** - * Duplicates an existing workflow - */ - duplicateWorkflow: async (sourceId: string) => { - const { workflows } = get() - const sourceWorkflow = workflows[sourceId] - - if (!sourceWorkflow) { - set({ error: `Workflow ${sourceId} not found` }) - return null - } - - // Get the workspace ID from the source workflow (required) - const workspaceId = sourceWorkflow.workspaceId - - // Call the server to duplicate the workflow - server generates all IDs - let duplicatedWorkflow - try { - const response = await fetch(`/api/workflows/${sourceId}/duplicate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: `${sourceWorkflow.name} (Copy)`, - description: sourceWorkflow.description, - color: sourceWorkflow.color, - workspaceId: workspaceId, - folderId: sourceWorkflow.folderId, - }), - }) - - if (!response.ok) { - throw new Error(`Failed to duplicate workflow: ${response.statusText}`) - } - - duplicatedWorkflow = await response.json() - logger.info( - `Successfully duplicated workflow ${sourceId} to ${duplicatedWorkflow.id} with ${duplicatedWorkflow.blocksCount} blocks, ${duplicatedWorkflow.edgesCount} edges, ${duplicatedWorkflow.subflowsCount} subflows` - ) - } catch (error) { - logger.error(`Failed to duplicate workflow ${sourceId}:`, error) - set({ - error: `Failed to duplicate workflow: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) - return null - } - - const id = duplicatedWorkflow.id - - const newWorkflow: WorkflowMetadata = { - id, - name: `${sourceWorkflow.name} (Copy)`, - lastModified: new Date(), - createdAt: new Date(), - description: sourceWorkflow.description, - color: getNextWorkflowColor(), - workspaceId, - folderId: sourceWorkflow.folderId, - sortOrder: duplicatedWorkflow.sortOrder ?? 0, - } - - // Get the current workflow state to copy from - const currentWorkflowState = useWorkflowStore.getState() - - // If we're duplicating the active workflow, use current state - // Otherwise, we need to fetch it from DB or use empty state - let sourceState: any - - if (sourceId === get().activeWorkflowId) { - // Source is the active workflow, copy current state - sourceState = { - blocks: currentWorkflowState.blocks || {}, - edges: currentWorkflowState.edges || [], - loops: currentWorkflowState.loops || {}, - parallels: currentWorkflowState.parallels || {}, - } - } else { - const { workflowState } = buildDefaultWorkflowArtifacts() - sourceState = { - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - } - } - - // Create the new workflow state with copied content - const newState = { - blocks: sourceState.blocks, - edges: sourceState.edges, - loops: sourceState.loops, - parallels: sourceState.parallels, - workspaceId, - deploymentStatuses: {}, - lastSaved: Date.now(), - } - - // Add workflow to registry - set((state) => ({ - workflows: { - ...state.workflows, - [id]: newWorkflow, - }, - error: null, - })) - - // Copy subblock values if duplicating active workflow - if (sourceId === get().activeWorkflowId) { - const sourceSubblockValues = useSubBlockStore.getState().workflowValues[sourceId] || {} - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [id]: sourceSubblockValues, - }, - })) - } else { - // Initialize subblock values for starter block - const subblockValues: Record> = {} - Object.entries(newState.blocks).forEach(([blockId, block]) => { - const blockState = block as any - subblockValues[blockId] = {} - Object.entries(blockState.subBlocks || {}).forEach(([subblockId, subblock]) => { - subblockValues[blockId][subblockId] = (subblock as any).value - }) - }) - - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [id]: subblockValues, - }, - })) - } - - try { - await useVariablesStore.getState().loadForWorkflow(id) - } catch (error) { - logger.warn(`Error hydrating variables for duplicated workflow ${id}:`, error) - } - - logger.info( - `Duplicated workflow ${sourceId} to ${id} in workspace ${workspaceId || 'none'}` - ) - - return id - }, - - removeWorkflow: async (id: string) => { - const { workflows, activeWorkflowId } = get() - const workflowToDelete = workflows[id] - - if (!workflowToDelete) { - logger.warn(`Attempted to delete non-existent workflow: ${id}`) - return - } - - const isDeletingActiveWorkflow = activeWorkflowId === id - - await withOptimisticUpdate({ - getCurrentState: () => ({ - workflows: { ...get().workflows }, - activeWorkflowId: get().activeWorkflowId, - subBlockValues: { ...useSubBlockStore.getState().workflowValues }, - workflowStoreState: isDeletingActiveWorkflow - ? { - blocks: { ...useWorkflowStore.getState().blocks }, - edges: [...useWorkflowStore.getState().edges], - loops: { ...useWorkflowStore.getState().loops }, - parallels: { ...useWorkflowStore.getState().parallels }, - lastSaved: useWorkflowStore.getState().lastSaved, - } - : null, - }), - optimisticUpdate: () => { - const newWorkflows = { ...get().workflows } - delete newWorkflows[id] - - const currentSubBlockValues = useSubBlockStore.getState().workflowValues - const newWorkflowValues = { ...currentSubBlockValues } - delete newWorkflowValues[id] - useSubBlockStore.setState({ workflowValues: newWorkflowValues }) - - let newActiveWorkflowId = get().activeWorkflowId - if (isDeletingActiveWorkflow) { - newActiveWorkflowId = null - - useWorkflowStore.setState({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - lastSaved: Date.now(), - }) - - logger.info( - `Cleared active workflow ${id} - user will need to manually select another workflow` - ) - } - - set({ - workflows: newWorkflows, - activeWorkflowId: newActiveWorkflowId, - error: null, - }) - - logger.info(`Removed workflow ${id} from local state (optimistic)`) - }, - apiCall: async () => { - const response = await fetch(`/api/workflows/${id}`, { - method: 'DELETE', - }) - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })) - throw new Error(error.error || 'Failed to delete workflow') - } - - logger.info(`Successfully deleted workflow ${id} from database`) - }, - rollback: (originalState) => { - set({ - workflows: originalState.workflows, - activeWorkflowId: originalState.activeWorkflowId, - }) - - useSubBlockStore.setState({ workflowValues: originalState.subBlockValues }) - - if (originalState.workflowStoreState) { - useWorkflowStore.getState().replaceWorkflowState(originalState.workflowStoreState) - logger.info(`Restored workflow store state for workflow ${id}`) - } - - logger.info(`Rolled back deletion of workflow ${id}`) - }, - errorMessage: `Failed to delete workflow ${id}`, - }) - }, - - updateWorkflow: async (id: string, metadata: Partial) => { - const { workflows } = get() - const workflow = workflows[id] - if (!workflow) { - logger.warn(`Cannot update workflow ${id}: not found in registry`) - return - } - - await withOptimisticUpdate({ - getCurrentState: () => workflow, - optimisticUpdate: () => { - set((state) => ({ - workflows: { - ...state.workflows, - [id]: { - ...workflow, - ...metadata, - lastModified: new Date(), - createdAt: workflow.createdAt, // Preserve creation date - }, - }, - error: null, - })) - }, - apiCall: async () => { - const response = await fetch(`/api/workflows/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(metadata), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Failed to update workflow') - } - - const { workflow: updatedWorkflow } = await response.json() - logger.info(`Successfully updated workflow ${id} metadata`, metadata) - - set((state) => ({ - workflows: { - ...state.workflows, - [id]: { - ...state.workflows[id], - name: updatedWorkflow.name, - description: updatedWorkflow.description, - color: updatedWorkflow.color, - folderId: updatedWorkflow.folderId, - lastModified: new Date(updatedWorkflow.updatedAt), - createdAt: updatedWorkflow.createdAt - ? new Date(updatedWorkflow.createdAt) - : state.workflows[id].createdAt, - }, - }, - })) - }, - rollback: (originalWorkflow) => { - set((state) => ({ - workflows: { - ...state.workflows, - [id]: originalWorkflow, // Revert to original state - }, - error: `Failed to update workflow: ${metadata.name ? 'name' : 'metadata'}`, - })) - }, - errorMessage: `Failed to update workflow ${id} metadata`, - }) - }, - logout: () => { logger.info('Logging out - clearing all workflow data') resetWorkflowStores() + // Clear the React Query cache to remove all server state + getQueryClient().clear() + set({ activeWorkflowId: null, - workflows: {}, deploymentStatuses: {}, error: null, hydration: initialHydration, @@ -744,7 +379,6 @@ export const useWorkflowRegistry = create()( const copiedSubBlockValues: Record> = {} const blockIdSet = new Set(blockIds) - // Auto-include nested nodes from selected subflows blockIds.forEach((blockId) => { const loop = workflowStore.loops[blockId] if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n)) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 1b22fe87d04..550aa0f4a04 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -32,13 +32,7 @@ export interface WorkflowMetadata { isSandbox?: boolean } -export type HydrationPhase = - | 'idle' - | 'metadata-loading' - | 'metadata-ready' - | 'state-loading' - | 'ready' - | 'error' +export type HydrationPhase = 'idle' | 'state-loading' | 'ready' | 'error' export interface HydrationState { phase: HydrationPhase @@ -49,7 +43,6 @@ export interface HydrationState { } export interface WorkflowRegistryState { - workflows: Record activeWorkflowId: string | null error: string | null deploymentStatuses: Record @@ -59,15 +52,9 @@ export interface WorkflowRegistryState { } export interface WorkflowRegistryActions { - beginMetadataLoad: (workspaceId: string) => void - completeMetadataLoad: (workspaceId: string, workflows: WorkflowMetadata[]) => void - failMetadataLoad: (workspaceId: string | null, error: string) => void setActiveWorkflow: (id: string) => Promise loadWorkflowState: (workflowId: string) => Promise switchToWorkspace: (id: string) => Promise - removeWorkflow: (id: string) => Promise - updateWorkflow: (id: string, metadata: Partial) => Promise - duplicateWorkflow: (sourceId: string) => Promise getWorkflowDeploymentStatus: (workflowId: string | null) => DeploymentStatus | null setDeploymentStatus: ( workflowId: string | null,