From 1fcff2bbd95042cd8abbe1befbb2241f059d520a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 12:22:34 -0700 Subject: [PATCH 01/11] fix(deploy): consolidate deployment detection into single source of truth --- .../app/api/workflows/[id]/deploy/route.ts | 49 +----- .../app/api/workflows/[id]/status/route.ts | 62 +------- apps/sim/app/api/workflows/utils.ts | 45 ++++++ .../components/deploy-modal/deploy-modal.tsx | 19 +-- .../panel/components/deploy/deploy.tsx | 25 +-- .../panel/components/deploy/hooks/index.ts | 1 - .../deploy/hooks/use-change-detection.ts | 78 +++++++++- .../deploy/hooks/use-deployed-state.ts | 110 ------------- .../components/deploy/hooks/use-deployment.ts | 61 ++------ .../components/tool-input/tool-input.tsx | 14 +- .../hooks/use-child-workflow.ts | 18 +-- .../workflow-block/workflow-block.tsx | 4 +- apps/sim/hooks/queries/deployments.ts | 83 +++++++--- apps/sim/hooks/queries/workflows.ts | 146 +----------------- 14 files changed, 244 insertions(+), 471 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 5ad26782382..6d2dbbaf4f5 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,6 +1,6 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' +import { db, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { generateRequestId } from '@/lib/core/utils/request' @@ -22,8 +22,11 @@ import { validateWorkflowSchedules, } from '@/lib/workflows/schedules' import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { + checkNeedsRedeployment, + createErrorResponse, + createSuccessResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') @@ -55,43 +58,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } - let needsRedeployment = false - const [active] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) - - if (active?.state) { - const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils') - const normalizedData = await loadWorkflowFromNormalizedTables(id) - if (normalizedData) { - const [workflowRecord] = await db - .select({ variables: workflow.variables }) - .from(workflow) - .where(eq(workflow.id, id)) - .limit(1) - - const currentState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - variables: workflowRecord?.variables || {}, - } - const { hasWorkflowChanged } = await import('@/lib/workflows/comparison') - needsRedeployment = hasWorkflowChanged( - currentState as WorkflowState, - active.state as WorkflowState - ) - } - } + const needsRedeployment = await checkNeedsRedeployment(id) logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`) diff --git a/apps/sim/app/api/workflows/[id]/status/route.ts b/apps/sim/app/api/workflows/[id]/status/route.ts index f53fbe05e4d..ac428d414fb 100644 --- a/apps/sim/app/api/workflows/[id]/status/route.ts +++ b/apps/sim/app/api/workflows/[id]/status/route.ts @@ -1,13 +1,12 @@ -import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { generateRequestId } from '@/lib/core/utils/request' -import { hasWorkflowChanged } from '@/lib/workflows/comparison' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { + checkNeedsRedeployment, + createErrorResponse, + createSuccessResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -23,54 +22,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return createErrorResponse(validation.error.message, validation.error.status) } - let needsRedeployment = false - - if (validation.workflow.isDeployed) { - const normalizedData = await loadWorkflowFromNormalizedTables(id) - - if (!normalizedData) { - return createSuccessResponse({ - isDeployed: validation.workflow.isDeployed, - deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, - needsRedeployment: false, - }) - } - - const [workflowRecord] = await db - .select({ variables: workflow.variables }) - .from(workflow) - .where(eq(workflow.id, id)) - .limit(1) - - const currentState = { - blocks: normalizedData.blocks, - edges: normalizedData.edges, - loops: normalizedData.loops, - parallels: normalizedData.parallels, - variables: workflowRecord?.variables || {}, - lastSaved: Date.now(), - } - - const [active] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, id), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) - - if (active?.state) { - needsRedeployment = hasWorkflowChanged( - currentState as WorkflowState, - active.state as WorkflowState - ) - } - } + const needsRedeployment = validation.workflow.isDeployed + ? await checkNeedsRedeployment(id) + : false return createSuccessResponse({ isDeployed: validation.workflow.isDeployed, diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index a6646d39505..2e64ed5057a 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -1,6 +1,11 @@ +import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' +import { and, desc, eq } from 'drizzle-orm' import { NextResponse } from 'next/server' +import { hasWorkflowChanged } from '@/lib/workflows/comparison' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowUtils') @@ -18,6 +23,46 @@ export function createSuccessResponse(data: any) { return NextResponse.json(data) } +/** + * Checks whether a deployed workflow has changes that require redeployment. + * Compares the current persisted state (from normalized tables) against the + * active deployment version state. + * + * This is the single source of truth for redeployment detection — used by + * both the /deploy and /status endpoints to ensure consistent results. + */ +export async function checkNeedsRedeployment(workflowId: string): Promise { + const [active] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1) + + if (!active?.state) return false + + const [normalizedData, [workflowRecord]] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId), + db.select({ variables: workflow.variables }).from(workflow).where(eq(workflow.id, workflowId)).limit(1), + ]) + if (!normalizedData) return false + + const currentState = { + blocks: normalizedData.blocks, + edges: normalizedData.edges, + loops: normalizedData.loops, + parallels: normalizedData.parallels, + variables: workflowRecord?.variables || {}, + } + + return hasWorkflowChanged(currentState as WorkflowState, active.state as WorkflowState) +} + /** * Verifies user's workspace permissions using the permissions table * @param userId User ID to check 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 0c8b58b7b4c..ff81a8bcee1 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 @@ -25,7 +25,7 @@ import { startsWithUuid } from '@/executor/constants' import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents' import { useApiKeys } from '@/hooks/queries/api-keys' import { - deploymentKeys, + invalidateDeploymentQueries, useActivateDeploymentVersion, useChatDeploymentInfo, useDeploymentInfo, @@ -61,7 +61,6 @@ interface DeployModalProps { needsRedeployment: boolean deployedState: WorkflowState isLoadingDeployedState: boolean - refetchDeployedState: () => Promise } interface WorkflowDeploymentInfoUI { @@ -84,7 +83,6 @@ export function DeployModal({ needsRedeployment, deployedState, isLoadingDeployedState, - refetchDeployedState, }: DeployModalProps) { const queryClient = useQueryClient() const { navigateToSettings } = useSettingsNavigation() @@ -298,17 +296,17 @@ export function DeployModal({ setDeployWarnings([]) try { + // Deploy mutation handles query invalidation in its onSuccess callback const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) if (result.warnings && result.warnings.length > 0) { setDeployWarnings(result.warnings) } - await refetchDeployedState() } catch (error: unknown) { logger.error('Error deploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' setDeployError(errorMessage) } - }, [workflowId, deployMutation, refetchDeployedState]) + }, [workflowId, deployMutation]) const handlePromoteToLive = useCallback( async (version: number) => { @@ -321,13 +319,12 @@ export function DeployModal({ if (result.warnings && result.warnings.length > 0) { setDeployWarnings(result.warnings) } - await refetchDeployedState() } catch (error) { logger.error('Error promoting version:', { error }) throw error } }, - [workflowId, activateVersionMutation, refetchDeployedState] + [workflowId, activateVersionMutation] ) const handleUndeploy = useCallback(async () => { @@ -367,13 +364,12 @@ export function DeployModal({ if (result.warnings && result.warnings.length > 0) { setDeployWarnings(result.warnings) } - await refetchDeployedState() } catch (error: unknown) { logger.error('Error redeploying workflow:', { error }) const errorMessage = error instanceof Error ? error.message : 'Failed to redeploy workflow' setDeployError(errorMessage) } - }, [workflowId, deployMutation, refetchDeployedState]) + }, [workflowId, deployMutation]) const handleCloseModal = useCallback(() => { setChatSubmitting(false) @@ -385,9 +381,8 @@ export function DeployModal({ const handleChatDeployed = useCallback(async () => { if (!workflowId) return - queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }) + invalidateDeploymentQueries(queryClient, workflowId) - await refetchDeployedState() useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(workflowId, false) if (chatSuccessTimeoutRef.current) { @@ -395,7 +390,7 @@ export function DeployModal({ } setChatSuccess(true) chatSuccessTimeoutRef.current = setTimeout(() => setChatSuccess(false), 2000) - }, [workflowId, queryClient, refetchDeployedState]) + }, [workflowId, queryClient]) const handleRefetchChat = useCallback(async () => { await refetchChatInfo() 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 ad8d3b098da..fba84a57e8a 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 @@ -6,10 +6,10 @@ import { Button, Tooltip } from '@/components/emcn' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, - useDeployedState, useDeployment, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' +import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -38,24 +38,32 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP ) const isDeployed = deploymentStatus?.isDeployed || false - // Fetch and manage deployed state - const { deployedState, isLoadingDeployedState, refetchDeployedState } = useDeployedState({ - workflowId: activeWorkflowId, - isDeployed, - isRegistryLoading, - }) + // Server-side deployment info (authoritative source for needsRedeployment) + const { data: deploymentInfoData, isLoading: isLoadingDeploymentInfo } = useDeploymentInfo( + activeWorkflowId, + { enabled: isDeployed && !isRegistryLoading } + ) + + // Fetch deployed state snapshot for change detection and modal + const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading + const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState( + activeWorkflowId, + { enabled: isDeployedStateEnabled } + ) + const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null const { changeDetected } = useChangeDetection({ workflowId: activeWorkflowId, deployedState, isLoadingDeployedState, + serverNeedsRedeployment: deploymentInfoData?.needsRedeployment, + isServerLoading: isLoadingDeploymentInfo, }) // Handle deployment operations const { isDeploying, handleDeployClick } = useDeployment({ workflowId: activeWorkflowId, isDeployed, - refetchDeployedState, }) const isEmpty = !hasBlocks() @@ -122,7 +130,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP needsRedeployment={changeDetected} deployedState={deployedState!} isLoadingDeployedState={isLoadingDeployedState} - refetchDeployedState={refetchDeployedState} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts index 6d0f191100d..9bd9bf02271 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/index.ts @@ -1,3 +1,2 @@ export { useChangeDetection } from './use-change-detection' -export { useDeployedState } from './use-deployed-state' export { useDeployment } from './use-deployment' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 9a90c663321..8115448c34a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -1,6 +1,8 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' +import { deploymentKeys } from '@/hooks/queries/deployments' import { useVariablesStore } from '@/stores/panel/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -10,21 +12,34 @@ interface UseChangeDetectionProps { workflowId: string | null deployedState: WorkflowState | null isLoadingDeployedState: boolean + serverNeedsRedeployment: boolean | undefined + isServerLoading: boolean } /** * Detects meaningful changes between current workflow state and deployed state. - * Performs comparison entirely on the client - no API calls needed. + * + * Uses the server-side needsRedeployment (from useDeploymentInfo) as the + * authoritative signal. The server compares the persisted DB state to the + * deployed version state, which avoids false positives from client-side + * representation differences. + * + * When the workflow store is updated (e.g. after auto-save), the deployment + * info query is invalidated so the server can recheck for changes. */ export function useChangeDetection({ workflowId, deployedState, isLoadingDeployedState, + serverNeedsRedeployment, + isServerLoading, }: UseChangeDetectionProps) { + const queryClient = useQueryClient() const blocks = useWorkflowStore((state) => state.blocks) const edges = useWorkflowStore((state) => state.edges) const loops = useWorkflowStore((state) => state.loops) const parallels = useWorkflowStore((state) => state.parallels) + const lastSaved = useWorkflowStore((state) => state.lastSaved) const subBlockValues = useSubBlockStore((state) => workflowId ? state.workflowValues[workflowId] : null ) @@ -40,8 +55,47 @@ export function useChangeDetection({ return vars }, [workflowId, allVariables]) + // Track initial lastSaved to detect saves after load. + // Debounced to avoid redundant API calls during rapid auto-saves. + const initialLastSavedRef = useRef(undefined) + + useEffect(() => { + if (lastSaved !== undefined && initialLastSavedRef.current === undefined) { + initialLastSavedRef.current = lastSaved + return + } + + if ( + lastSaved === undefined || + initialLastSavedRef.current === undefined || + lastSaved === initialLastSavedRef.current || + !workflowId + ) { + return + } + + initialLastSavedRef.current = lastSaved + + const timer = setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: deploymentKeys.info(workflowId), + }) + }, 500) + + return () => clearTimeout(timer) + }, [lastSaved, workflowId, queryClient]) + + // Reset tracking when workflow changes + useEffect(() => { + initialLastSavedRef.current = undefined + }, [workflowId]) + + // Skip expensive state merge when server result is available (the common path). + // Only build currentState for the client-side fallback comparison. + const needsClientFallback = serverNeedsRedeployment === undefined && !isServerLoading + const currentState = useMemo((): WorkflowState | null => { - if (!workflowId) return null + if (!needsClientFallback || !workflowId) return null const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {}) @@ -52,14 +106,24 @@ export function useChangeDetection({ parallels, variables: workflowVariables, } as WorkflowState & { variables: Record } - }, [workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables]) + }, [needsClientFallback, workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables]) const changeDetected = useMemo(() => { - if (!currentState || !deployedState || isLoadingDeployedState) { - return false + if (isServerLoading) return false + + if (serverNeedsRedeployment !== undefined) { + return serverNeedsRedeployment } + + if (!currentState || !deployedState || isLoadingDeployedState) return false return hasWorkflowChanged(currentState, deployedState) - }, [currentState, deployedState, isLoadingDeployedState]) + }, [ + currentState, + deployedState, + isLoadingDeployedState, + serverNeedsRedeployment, + isServerLoading, + ]) return { changeDetected } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts deleted file mode 100644 index 0bcdad7006e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployed-state.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -const logger = createLogger('useDeployedState') - -interface UseDeployedStateProps { - workflowId: string | null - isDeployed: boolean - isRegistryLoading: boolean -} - -/** - * Hook to fetch and manage deployed workflow state - * Includes race condition protection for workflow changes - */ -export function useDeployedState({ - workflowId, - isDeployed, - isRegistryLoading, -}: UseDeployedStateProps) { - const [deployedState, setDeployedState] = useState(null) - const [isLoadingDeployedState, setIsLoadingDeployedState] = useState(false) - - const setNeedsRedeploymentFlag = useWorkflowRegistry( - (state) => state.setWorkflowNeedsRedeployment - ) - - const fetchDeployedState = useCallback(async () => { - const registry = useWorkflowRegistry.getState() - const currentWorkflowId = registry.activeWorkflowId - const deploymentStatus = currentWorkflowId - ? registry.getWorkflowDeploymentStatus(currentWorkflowId) - : null - const currentIsDeployed = deploymentStatus?.isDeployed ?? false - - if (!currentWorkflowId || !currentIsDeployed) { - setDeployedState(null) - return - } - - const requestWorkflowId = currentWorkflowId - - try { - setIsLoadingDeployedState(true) - - const response = await fetch(`/api/workflows/${requestWorkflowId}/deployed`) - - if (requestWorkflowId !== useWorkflowRegistry.getState().activeWorkflowId) { - logger.debug('Workflow changed during deployed state fetch, ignoring response') - return - } - - if (!response.ok) { - if (response.status === 404) { - setDeployedState(null) - return - } - throw new Error(`Failed to fetch deployed state: ${response.statusText}`) - } - - const data = await response.json() - - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setDeployedState(data.deployedState || null) - } else { - logger.debug('Workflow changed after deployed state response, ignoring result') - } - } catch (error) { - logger.error('Error fetching deployed state:', { error }) - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setDeployedState(null) - } - } finally { - if (requestWorkflowId === useWorkflowRegistry.getState().activeWorkflowId) { - setIsLoadingDeployedState(false) - } - } - }, []) - - useEffect(() => { - if (!workflowId) { - setDeployedState(null) - setIsLoadingDeployedState(false) - return - } - - if (isRegistryLoading) { - setDeployedState(null) - setIsLoadingDeployedState(false) - return - } - - if (isDeployed) { - setNeedsRedeploymentFlag(workflowId, false) - fetchDeployedState() - } else { - setDeployedState(null) - setIsLoadingDeployedState(false) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workflowId, isDeployed, isRegistryLoading, setNeedsRedeploymentFlag]) - - return { - deployedState, - isLoadingDeployedState, - refetchDeployedState: fetchDeployedState, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index b6a5d585e32..e964c0ffb7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -1,36 +1,27 @@ -import { useCallback, useState } from 'react' -import { createLogger } from '@sim/logger' +import { useCallback } from 'react' import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks' +import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useNotificationStore } from '@/stores/notifications' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -const logger = createLogger('useDeployment') - interface UseDeploymentProps { workflowId: string | null isDeployed: boolean - refetchDeployedState: () => Promise } /** - * Hook to manage deployment operations (deploy, undeploy, redeploy) + * Hook to manage the deploy button click in the editor header. + * First deploy: runs pre-deploy checks, then deploys via mutation and opens modal. + * Already deployed: opens modal directly (validation happens on Update in modal). */ export function useDeployment({ workflowId, isDeployed, - refetchDeployedState, }: UseDeploymentProps) { - const [isDeploying, setIsDeploying] = useState(false) - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) + const deployMutation = useDeployWorkflow() const addNotification = useNotificationStore((state) => state.addNotification) - /** - * Handle deploy button click - * First deploy: calls API to deploy, then opens modal on success - * Already deployed: opens modal directly (validation happens on Update in modal) - */ const handleDeployClick = useCallback(async () => { if (!workflowId) return { success: false, shouldOpenModal: false } @@ -56,39 +47,10 @@ export function useDeployment({ return { success: false, shouldOpenModal: false } } - setIsDeploying(true) try { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (response.ok) { - const responseData = await response.json() - const isDeployedStatus = responseData.isDeployed ?? false - const deployedAtTime = responseData.deployedAt - ? new Date(responseData.deployedAt) - : undefined - setDeploymentStatus(workflowId, isDeployedStatus, deployedAtTime, responseData.apiKey || '') - await refetchDeployedState() - return { success: true, shouldOpenModal: true } - } - - const errorData = await response.json() - const errorMessage = errorData.error || 'Failed to deploy workflow' - addNotification({ - level: 'error', - message: errorMessage, - workflowId, - }) - return { success: false, shouldOpenModal: false } + await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) + return { success: true, shouldOpenModal: true } } catch (error) { - logger.error('Error deploying workflow:', error) const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' addNotification({ level: 'error', @@ -96,13 +58,12 @@ export function useDeployment({ workflowId, }) return { success: false, shouldOpenModal: false } - } finally { - setIsDeploying(false) } - }, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus, addNotification]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutateAsync is a stable reference + }, [workflowId, isDeployed, addNotification]) return { - isDeploying, + isDeploying: deployMutation.isPending, handleDeployClick, } } 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 467800aa263..b2dca17101c 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 @@ -59,12 +59,8 @@ import { useMcpToolsEvents, useStoredMcpTools, } from '@/hooks/queries/mcp' -import { - useChildDeploymentStatus, - useDeployChildWorkflow, - useWorkflowState, - useWorkflows, -} from '@/hooks/queries/workflows' +import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments' +import { useWorkflowState, useWorkflows } 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' @@ -210,12 +206,12 @@ function WorkflowToolDeployBadge({ workflowId: string onDeploySuccess?: () => void }) { - const { data, isLoading } = useChildDeploymentStatus(workflowId) - const deployMutation = useDeployChildWorkflow() + const { data, isLoading } = useDeploymentInfo(workflowId) + const deployMutation = useDeployWorkflow() const userPermissions = useUserPermissionsContext() const isDeployed = data?.isDeployed ?? null - const needsRedeploy = data?.needsRedeploy ?? false + const needsRedeploy = data?.needsRedeployment ?? false const isDeploying = deployMutation.isPending const deployWorkflow = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts index cf5756f902e..af1f459c58b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts @@ -1,6 +1,6 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types' -import { useChildDeploymentStatus } from '@/hooks/queries/workflows' +import { useDeploymentInfo } from '@/hooks/queries/deployments' /** * Return type for the useChildWorkflow hook @@ -8,20 +8,18 @@ import { useChildDeploymentStatus } from '@/hooks/queries/workflows' export interface UseChildWorkflowReturn { /** The ID of the child workflow if configured */ childWorkflowId: string | undefined - /** The active version of the child workflow */ - childActiveVersion: number | null /** Whether the child workflow is deployed */ childIsDeployed: boolean | null /** Whether the child workflow needs redeployment due to changes */ childNeedsRedeploy: boolean - /** Whether the child version information is loading */ + /** Whether the child deployment info is loading */ isLoadingChildVersion: boolean } /** * Custom hook for managing child workflow information for workflow selector blocks. - * Cache invalidation is handled automatically by React Query when using - * the useDeployChildWorkflow mutation. + * Uses the shared useDeploymentInfo query — the same source of truth as the + * editor header's Deploy button — for consistent deployment status detection. * * @param blockId - The ID of the block * @param blockType - The type of the block @@ -53,18 +51,16 @@ export function useChildWorkflow( } } - const { data, isLoading, isPending } = useChildDeploymentStatus( - isWorkflowSelector ? childWorkflowId : undefined + const { data, isLoading, isPending } = useDeploymentInfo( + isWorkflowSelector ? (childWorkflowId ?? null) : null ) - const childActiveVersion = data?.activeVersion ?? null const childIsDeployed = data?.isDeployed ?? null - const childNeedsRedeploy = data?.needsRedeploy ?? false + const childNeedsRedeploy = data?.needsRedeployment ?? false const isLoadingChildVersion = isLoading || isPending return { childWorkflowId, - childActiveVersion, childIsDeployed, childNeedsRedeploy, isLoadingChildVersion, 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 b2b470c21df..33846516cce 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 @@ -45,7 +45,7 @@ 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 { useDeployChildWorkflow } from '@/hooks/queries/workflows' +import { useDeployWorkflow } from '@/hooks/queries/deployments' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { useVariablesStore } from '@/stores/panel' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -918,7 +918,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ data.subBlockValues ) - const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployChildWorkflow() + const { mutate: deployChildWorkflow, isPending: isDeploying } = useDeployWorkflow() const userPermissions = useUserPermissionsContext() diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 3b341ba2b90..2177d1bc103 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -1,9 +1,11 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' +import type { QueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { fetchDeploymentVersionState } from './workflows' +import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { fetchDeploymentVersionState, workflowKeys } from './workflows' const logger = createLogger('DeploymentQueries') @@ -14,6 +16,8 @@ export const deploymentKeys = { all: ['deployments'] as const, infos: () => [...deploymentKeys.all, 'info'] as const, info: (workflowId: string | null) => [...deploymentKeys.infos(), workflowId ?? ''] as const, + deployedState: (workflowId: string | null) => + [...deploymentKeys.all, 'deployedState', workflowId ?? ''] as const, allVersions: () => [...deploymentKeys.all, 'versions'] as const, versions: (workflowId: string | null) => [...deploymentKeys.allVersions(), workflowId ?? ''] as const, @@ -29,6 +33,18 @@ export const deploymentKeys = { formDetail: (formId: string | null) => [...deploymentKeys.formDetails(), formId ?? ''] as const, } +/** + * Invalidates the core deployment queries (info, deployedState, versions) for a workflow. + * Used by mutation onSuccess callbacks and manual invalidation after chat deployments. + */ +export function invalidateDeploymentQueries(queryClient: QueryClient, workflowId: string) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: deploymentKeys.info(workflowId) }), + queryClient.invalidateQueries({ queryKey: deploymentKeys.deployedState(workflowId) }), + queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(workflowId) }), + ]) +} + /** * Response type from /api/workflows/[id]/deploy GET endpoint */ @@ -76,6 +92,37 @@ export function useDeploymentInfo(workflowId: string | null, options?: { enabled }) } +/** + * Fetches the deployed workflow state snapshot for a workflow + */ +async function fetchDeployedWorkflowState(workflowId: string): Promise { + const response = await fetch(`/api/workflows/${workflowId}/deployed`) + + if (!response.ok) { + if (response.status === 404) return null + throw new Error('Failed to fetch deployed workflow state') + } + + const data = await response.json() + return data.deployedState || null +} + +/** + * Hook to fetch the deployed workflow state snapshot. + * Returns the full workflow state at the time of the last active deployment. + */ +export function useDeployedWorkflowState( + workflowId: string | null, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: deploymentKeys.deployedState(workflowId), + queryFn: () => fetchDeployedWorkflowState(workflowId!), + enabled: Boolean(workflowId) && (options?.enabled ?? true), + staleTime: 30 * 1000, + }) +} + /** * Response type from /api/workflows/[id]/deployments GET endpoint */ @@ -307,12 +354,12 @@ export function useDeployWorkflow() { useWorkflowRegistry.getState().setWorkflowNeedsRedeployment(variables.workflowId, false) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) + return Promise.all([ + invalidateDeploymentQueries(queryClient, variables.workflowId), + queryClient.invalidateQueries({ + queryKey: workflowKeys.state(variables.workflowId), + }), + ]) }, onError: (error) => { logger.error('Failed to deploy workflow', { error }) @@ -351,15 +398,12 @@ export function useUndeployWorkflow() { setDeploymentStatus(variables.workflowId, false) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.chatStatus(variables.workflowId), - }) + return Promise.all([ + invalidateDeploymentQueries(queryClient, variables.workflowId), + queryClient.invalidateQueries({ + queryKey: deploymentKeys.chatStatus(variables.workflowId), + }), + ]) }, onError: (error) => { logger.error('Failed to undeploy workflow', { error }) @@ -624,12 +668,7 @@ export function useActivateDeploymentVersion() { ) }, onSettled: (_, __, variables) => { - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) + return invalidateDeploymentQueries(queryClient, variables.workflowId) }, }) } diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts index 88d19f2cb3f..09c22e604d0 100644 --- a/apps/sim/hooks/queries/workflows.ts +++ b/apps/sim/hooks/queries/workflows.ts @@ -3,12 +3,12 @@ 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 { deploymentKeys } from '@/hooks/queries/deployments' import { createOptimisticMutationHandlers, generateTempId, } from '@/hooks/queries/utils/optimistic-mutation' import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order' +import { deploymentKeys } from '@/hooks/queries/deployments' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -25,8 +25,6 @@ export const workflowKeys = { lists: () => [...workflowKeys.all, 'list'] as const, list: (workspaceId: string | undefined, scope: WorkflowQueryScope = 'active') => [...workflowKeys.lists(), workspaceId ?? '', scope] as const, - deploymentStatus: (workflowId: string | undefined) => - [...workflowKeys.all, 'deploymentStatus', workflowId ?? ''] as const, deploymentVersions: () => [...workflowKeys.all, 'deploymentVersion'] as const, deploymentVersion: (workflowId: string | undefined, version: number | undefined) => [...workflowKeys.deploymentVersions(), workflowId ?? '', version ?? 0] as const, @@ -520,10 +518,10 @@ export function useRevertToVersion() { queryKey: workflowKeys.state(variables.workflowId), }) queryClient.invalidateQueries({ - queryKey: workflowKeys.deploymentStatus(variables.workflowId), + queryKey: deploymentKeys.info(variables.workflowId), }) queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), + queryKey: deploymentKeys.deployedState(variables.workflowId), }) queryClient.invalidateQueries({ queryKey: deploymentKeys.versions(variables.workflowId), @@ -590,144 +588,6 @@ export function useReorderWorkflows() { }) } -/** - * Child deployment status data returned from the API - */ -export interface ChildDeploymentStatus { - activeVersion: number | null - isDeployed: boolean - needsRedeploy: boolean -} - -/** - * Fetches deployment status for a child workflow. - * Uses Promise.all to fetch status and deployments in parallel for better performance. - */ -async function fetchChildDeploymentStatus( - workflowId: string, - signal?: AbortSignal -): Promise { - const fetchOptions = { - signal, - cache: 'no-store' as const, - headers: { 'Cache-Control': 'no-cache' }, - } - - const [statusRes, deploymentsRes] = await Promise.all([ - fetch(`/api/workflows/${workflowId}/status`, fetchOptions), - fetch(`/api/workflows/${workflowId}/deployments`, fetchOptions), - ]) - - if (!statusRes.ok) { - throw new Error('Failed to fetch workflow status') - } - - const statusData = await statusRes.json() - - let activeVersion: number | null = null - if (deploymentsRes.ok) { - const deploymentsJson = await deploymentsRes.json() - const versions = Array.isArray(deploymentsJson?.data?.versions) - ? deploymentsJson.data.versions - : Array.isArray(deploymentsJson?.versions) - ? deploymentsJson.versions - : [] - - const active = versions.find((v: { isActive?: boolean }) => v.isActive) - activeVersion = active ? Number(active.version) : null - } - - return { - activeVersion, - isDeployed: statusData.isDeployed || false, - needsRedeploy: statusData.needsRedeployment || false, - } -} - -/** - * Hook to fetch deployment status for a child workflow. - * Used by workflow selector blocks to show deployment badges. - */ -export function useChildDeploymentStatus(workflowId: string | undefined) { - return useQuery({ - queryKey: workflowKeys.deploymentStatus(workflowId), - queryFn: ({ signal }) => fetchChildDeploymentStatus(workflowId!, signal), - enabled: Boolean(workflowId), - staleTime: 0, - retry: false, - }) -} - -interface DeployChildWorkflowVariables { - workflowId: string -} - -interface DeployChildWorkflowResult { - isDeployed: boolean - deployedAt?: Date - apiKey?: string -} - -/** - * Mutation hook for deploying a child workflow. - * Invalidates the deployment status query on success. - */ -export function useDeployChildWorkflow() { - const queryClient = useQueryClient() - const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus) - - return useMutation({ - mutationFn: async ({ - workflowId, - }: DeployChildWorkflowVariables): Promise => { - const response = await fetch(`/api/workflows/${workflowId}/deploy`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - deployChatEnabled: false, - }), - }) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to deploy workflow') - } - - const responseData = await response.json() - return { - isDeployed: responseData.isDeployed ?? false, - deployedAt: responseData.deployedAt ? new Date(responseData.deployedAt) : undefined, - apiKey: responseData.apiKey || '', - } - }, - onSuccess: (data, variables) => { - logger.info('Child workflow deployed', { workflowId: variables.workflowId }) - - setDeploymentStatus(variables.workflowId, data.isDeployed, data.deployedAt, data.apiKey || '') - - queryClient.invalidateQueries({ - queryKey: workflowKeys.deploymentStatus(variables.workflowId), - }) - // Invalidate workflow state so tool input mappings refresh - queryClient.invalidateQueries({ - queryKey: workflowKeys.state(variables.workflowId), - }) - // Also invalidate deployment queries - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(variables.workflowId), - }) - queryClient.invalidateQueries({ - queryKey: deploymentKeys.versions(variables.workflowId), - }) - }, - onError: (error) => { - logger.error('Failed to deploy child workflow', { error }) - }, - }) -} - /** * Import workflow mutation (superuser debug) */ From b6a225583633e302d49f4f4142d908ce4abc3fac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 12:41:40 -0700 Subject: [PATCH 02/11] fix(deploy): address PR review feedback - Remove redundant isLoading check (subset of isPending in RQ v5) - Make deployedState prop nullable to avoid non-null assertion - Destructure mutateAsync to eliminate ESLint suppression - Guard debounced invalidation against stale workflowId on switch --- .../components/general/general.tsx | 2 +- .../components/deploy-modal/deploy-modal.tsx | 2 +- .../panel/components/deploy/deploy.tsx | 2 +- .../deploy/hooks/use-change-detection.ts | 28 ++++++++++++++----- .../components/deploy/hooks/use-deployment.ts | 14 ++++------ .../hooks/use-child-workflow.ts | 4 +-- 6 files changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 7c28f551c9d..f45d36959a8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -26,7 +26,7 @@ const logger = createLogger('GeneralDeploy') interface GeneralDeployProps { workflowId: string | null - deployedState: WorkflowState + deployedState?: WorkflowState | null isLoadingDeployedState: boolean versions: WorkflowDeploymentVersionResponse[] versionsLoading: boolean 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 ff81a8bcee1..fb5b18c5df1 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 @@ -59,7 +59,7 @@ interface DeployModalProps { workflowId: string | null isDeployed: boolean needsRedeployment: boolean - deployedState: WorkflowState + deployedState?: WorkflowState | null isLoadingDeployedState: boolean } 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 fba84a57e8a..2126cc6778b 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 @@ -128,7 +128,7 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP workflowId={activeWorkflowId} isDeployed={isDeployed} needsRedeployment={changeDetected} - deployedState={deployedState!} + deployedState={deployedState} isLoadingDeployedState={isLoadingDeployedState} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 8115448c34a..9cc2b603994 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -58,6 +58,14 @@ export function useChangeDetection({ // Track initial lastSaved to detect saves after load. // Debounced to avoid redundant API calls during rapid auto-saves. const initialLastSavedRef = useRef(undefined) + const workflowIdRef = useRef(workflowId) + + // Reset tracking when workflow changes — must run before the lastSaved effect + // to prevent spurious invalidation with a stale ref during workflow switches. + useEffect(() => { + workflowIdRef.current = workflowId + initialLastSavedRef.current = undefined + }, [workflowId]) useEffect(() => { if (lastSaved !== undefined && initialLastSavedRef.current === undefined) { @@ -76,20 +84,17 @@ export function useChangeDetection({ initialLastSavedRef.current = lastSaved + const capturedWorkflowId = workflowId const timer = setTimeout(() => { + if (workflowIdRef.current !== capturedWorkflowId) return queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(workflowId), + queryKey: deploymentKeys.info(capturedWorkflowId), }) }, 500) return () => clearTimeout(timer) }, [lastSaved, workflowId, queryClient]) - // Reset tracking when workflow changes - useEffect(() => { - initialLastSavedRef.current = undefined - }, [workflowId]) - // Skip expensive state merge when server result is available (the common path). // Only build currentState for the client-side fallback comparison. const needsClientFallback = serverNeedsRedeployment === undefined && !isServerLoading @@ -106,7 +111,16 @@ export function useChangeDetection({ parallels, variables: workflowVariables, } as WorkflowState & { variables: Record } - }, [needsClientFallback, workflowId, blocks, edges, loops, parallels, subBlockValues, workflowVariables]) + }, [ + needsClientFallback, + workflowId, + blocks, + edges, + loops, + parallels, + subBlockValues, + workflowVariables, + ]) const changeDetected = useMemo(() => { if (isServerLoading) return false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts index e964c0ffb7e..47ed17a7125 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-deployment.ts @@ -15,11 +15,8 @@ interface UseDeploymentProps { * First deploy: runs pre-deploy checks, then deploys via mutation and opens modal. * Already deployed: opens modal directly (validation happens on Update in modal). */ -export function useDeployment({ - workflowId, - isDeployed, -}: UseDeploymentProps) { - const deployMutation = useDeployWorkflow() +export function useDeployment({ workflowId, isDeployed }: UseDeploymentProps) { + const { mutateAsync, isPending: isDeploying } = useDeployWorkflow() const addNotification = useNotificationStore((state) => state.addNotification) const handleDeployClick = useCallback(async () => { @@ -48,7 +45,7 @@ export function useDeployment({ } try { - await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false }) + await mutateAsync({ workflowId, deployChatEnabled: false }) return { success: true, shouldOpenModal: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' @@ -59,11 +56,10 @@ export function useDeployment({ }) return { success: false, shouldOpenModal: false } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- mutateAsync is a stable reference - }, [workflowId, isDeployed, addNotification]) + }, [workflowId, isDeployed, addNotification, mutateAsync]) return { - isDeploying: deployMutation.isPending, + isDeploying, handleDeployClick, } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts index af1f459c58b..e14d1d3d672 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts @@ -51,13 +51,13 @@ export function useChildWorkflow( } } - const { data, isLoading, isPending } = useDeploymentInfo( + const { data, isPending } = useDeploymentInfo( isWorkflowSelector ? (childWorkflowId ?? null) : null ) const childIsDeployed = data?.isDeployed ?? null const childNeedsRedeploy = data?.needsRedeployment ?? false - const isLoadingChildVersion = isLoading || isPending + const isLoadingChildVersion = isPending return { childWorkflowId, From 2fa20f9f2fe9e59c22b3f528616d9102a3a2043c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 12:48:02 -0700 Subject: [PATCH 03/11] fix(deploy): clean up self-explanatory comments and fix unstable mutation dep - Remove self-explanatory comments in deploy.tsx, tool-input.tsx, use-child-workflow.ts - Tighten non-obvious comments in use-change-detection.ts - Destructure mutate/isPending in WorkflowToolDeployBadge to avoid unstable mutation object in useCallback deps (TanStack Query no-unstable-deps pattern) --- .../panel/components/deploy/deploy.tsx | 10 ---------- .../deploy/hooks/use-change-detection.ts | 6 ++---- .../components/tool-input/tool-input.tsx | 12 ++++-------- .../hooks/use-child-workflow.ts | 19 +++---------------- 4 files changed, 9 insertions(+), 38 deletions(-) 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 2126cc6778b..907b2722173 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 @@ -19,10 +19,6 @@ interface DeployProps { className?: string } -/** - * Deploy component that handles workflow deployment - * Manages deployed state, change detection, and deployment operations - */ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) @@ -32,7 +28,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP hydrationPhase === 'state-loading' const { hasBlocks } = useCurrentWorkflow() - // Get deployment status from registry const deploymentStatus = useWorkflowRegistry((state) => state.getWorkflowDeploymentStatus(activeWorkflowId) ) @@ -44,7 +39,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP { enabled: isDeployed && !isRegistryLoading } ) - // Fetch deployed state snapshot for change detection and modal const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState( activeWorkflowId, @@ -60,7 +54,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP isServerLoading: isLoadingDeploymentInfo, }) - // Handle deployment operations const { isDeploying, handleDeployClick } = useDeployment({ workflowId: activeWorkflowId, isDeployed, @@ -79,9 +72,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP } } - /** - * Get tooltip text based on current state - */ const getTooltipText = () => { if (isEmpty) { return 'Cannot deploy an empty workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 9cc2b603994..035dac6e47d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -55,13 +55,11 @@ export function useChangeDetection({ return vars }, [workflowId, allVariables]) - // Track initial lastSaved to detect saves after load. - // Debounced to avoid redundant API calls during rapid auto-saves. + // Tracks the lastSaved timestamp at mount to distinguish real saves from initial hydration. const initialLastSavedRef = useRef(undefined) const workflowIdRef = useRef(workflowId) - // Reset tracking when workflow changes — must run before the lastSaved effect - // to prevent spurious invalidation with a stale ref during workflow switches. + // Must run before the lastSaved effect to prevent stale-ref invalidation on workflow switch. useEffect(() => { workflowIdRef.current = workflowId initialLastSavedRef.current = undefined 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 b2dca17101c..d502415b57a 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 @@ -53,13 +53,13 @@ import { type CustomTool as CustomToolDefinition, useCustomTools, } from '@/hooks/queries/custom-tools' +import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments' import { useForceRefreshMcpTools, useMcpServers, useMcpToolsEvents, useStoredMcpTools, } from '@/hooks/queries/mcp' -import { useDeploymentInfo, useDeployWorkflow } from '@/hooks/queries/deployments' import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -196,9 +196,6 @@ function WorkflowInputMapperInput({ ) } -/** - * Badge component showing deployment status for workflow tools - */ function WorkflowToolDeployBadge({ workflowId, onDeploySuccess, @@ -207,17 +204,16 @@ function WorkflowToolDeployBadge({ onDeploySuccess?: () => void }) { const { data, isLoading } = useDeploymentInfo(workflowId) - const deployMutation = useDeployWorkflow() + const { mutate, isPending: isDeploying } = useDeployWorkflow() const userPermissions = useUserPermissionsContext() const isDeployed = data?.isDeployed ?? null const needsRedeploy = data?.needsRedeployment ?? false - const isDeploying = deployMutation.isPending const deployWorkflow = useCallback(() => { if (isDeploying || !workflowId || !userPermissions.canAdmin) return - deployMutation.mutate( + mutate( { workflowId }, { onSuccess: () => { @@ -225,7 +221,7 @@ function WorkflowToolDeployBadge({ }, } ) - }, [isDeploying, workflowId, userPermissions.canAdmin, deployMutation, onDeploySuccess]) + }, [isDeploying, workflowId, userPermissions.canAdmin, mutate, onDeploySuccess]) if (isLoading || (isDeployed && !needsRedeploy)) { return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts index e14d1d3d672..f13b80ec210 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-child-workflow.ts @@ -2,30 +2,17 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types' import { useDeploymentInfo } from '@/hooks/queries/deployments' -/** - * Return type for the useChildWorkflow hook - */ export interface UseChildWorkflowReturn { - /** The ID of the child workflow if configured */ childWorkflowId: string | undefined - /** Whether the child workflow is deployed */ childIsDeployed: boolean | null - /** Whether the child workflow needs redeployment due to changes */ childNeedsRedeploy: boolean - /** Whether the child deployment info is loading */ isLoadingChildVersion: boolean } /** - * Custom hook for managing child workflow information for workflow selector blocks. - * Uses the shared useDeploymentInfo query — the same source of truth as the - * editor header's Deploy button — for consistent deployment status detection. - * - * @param blockId - The ID of the block - * @param blockType - The type of the block - * @param isPreview - Whether the block is in preview mode - * @param previewSubBlockValues - The subblock values in preview mode - * @returns Child workflow configuration and deployment status + * Manages child workflow deployment status for workflow selector blocks. + * Uses the shared useDeploymentInfo query (same source of truth as the + * editor header's Deploy button) for consistent deployment detection. */ export function useChildWorkflow( blockId: string, From 9a348c808a11f6c5fce162a9699d5d7293311ced Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 12:49:34 -0700 Subject: [PATCH 04/11] lint --- apps/sim/app/api/workflows/utils.ts | 6 +++++- .../components/workflow-block/workflow-block.tsx | 2 +- apps/sim/hooks/queries/workflows.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index 2e64ed5057a..f460f551b1f 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -48,7 +48,11 @@ export async function checkNeedsRedeployment(workflowId: string): Promise Date: Mon, 16 Mar 2026 12:57:17 -0700 Subject: [PATCH 05/11] fix(deploy): skip expensive state merge when deployedState is null Avoid running mergeSubblockStateWithValues on every render for non-deployed workflows where changeDetected always returns false. --- .../panel/components/deploy/hooks/use-change-detection.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index 035dac6e47d..e2779b80eaa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -98,7 +98,7 @@ export function useChangeDetection({ const needsClientFallback = serverNeedsRedeployment === undefined && !isServerLoading const currentState = useMemo((): WorkflowState | null => { - if (!needsClientFallback || !workflowId) return null + if (!needsClientFallback || !workflowId || !deployedState) return null const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {}) @@ -112,6 +112,7 @@ export function useChangeDetection({ }, [ needsClientFallback, workflowId, + deployedState, blocks, edges, loops, From 3ea26288f87552136437bb67372b7a3475546568 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 13:08:43 -0700 Subject: [PATCH 06/11] fix(deploy): add missing workflow table import in deploy route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing type error — workflow table was used but not imported. --- apps/sim/app/api/workflows/[id]/deploy/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/api/workflows/[id]/deploy/route.ts b/apps/sim/app/api/workflows/[id]/deploy/route.ts index 6d2dbbaf4f5..fe84eda30c1 100644 --- a/apps/sim/app/api/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/workflows/[id]/deploy/route.ts @@ -1,4 +1,4 @@ -import { db, workflowDeploymentVersion } from '@sim/db' +import { db, workflow, workflowDeploymentVersion } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' From 44df76d517dc855413309d5b1daf9ab4945168cf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 13:10:44 -0700 Subject: [PATCH 07/11] fix(deploy): forward AbortSignal in fetchDeployedWorkflowState Match the pattern used by all other fetch helpers in the file so in-flight requests are cancelled on component unmount or query re-trigger. --- apps/sim/hooks/queries/deployments.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/sim/hooks/queries/deployments.ts b/apps/sim/hooks/queries/deployments.ts index 2177d1bc103..716408bebf3 100644 --- a/apps/sim/hooks/queries/deployments.ts +++ b/apps/sim/hooks/queries/deployments.ts @@ -95,8 +95,11 @@ export function useDeploymentInfo(workflowId: string | null, options?: { enabled /** * Fetches the deployed workflow state snapshot for a workflow */ -async function fetchDeployedWorkflowState(workflowId: string): Promise { - const response = await fetch(`/api/workflows/${workflowId}/deployed`) +async function fetchDeployedWorkflowState( + workflowId: string, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/workflows/${workflowId}/deployed`, { signal }) if (!response.ok) { if (response.status === 404) return null @@ -117,7 +120,7 @@ export function useDeployedWorkflowState( ) { return useQuery({ queryKey: deploymentKeys.deployedState(workflowId), - queryFn: () => fetchDeployedWorkflowState(workflowId!), + queryFn: ({ signal }) => fetchDeployedWorkflowState(workflowId!, signal), enabled: Boolean(workflowId) && (options?.enabled ?? true), staleTime: 30 * 1000, }) From c64a9fad6173cd2e9eb4922460c57f06cb8de047 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 13:19:16 -0700 Subject: [PATCH 08/11] perf(deploy): parallelize all DB queries in checkNeedsRedeployment All three queries (active version, normalized data, workflow variables) now run concurrently via Promise.all, saving one DB round trip on the common path. --- apps/sim/app/api/workflows/utils.ts | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index f460f551b1f..2420a06b478 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -32,21 +32,18 @@ export function createSuccessResponse(data: any) { * both the /deploy and /status endpoints to ensure consistent results. */ export async function checkNeedsRedeployment(workflowId: string): Promise { - const [active] = await db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) + const [[active], normalizedData, [workflowRecord]] = await Promise.all([ + db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1) - - if (!active?.state) return false - - const [normalizedData, [workflowRecord]] = await Promise.all([ + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1), loadWorkflowFromNormalizedTables(workflowId), db .select({ variables: workflow.variables }) @@ -54,7 +51,8 @@ export async function checkNeedsRedeployment(workflowId: string): Promise Date: Mon, 16 Mar 2026 13:29:57 -0700 Subject: [PATCH 09/11] fix(deploy): use sequential-then-parallel pattern in checkNeedsRedeployment --- apps/sim/app/api/workflows/utils.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/api/workflows/utils.ts b/apps/sim/app/api/workflows/utils.ts index 2420a06b478..f460f551b1f 100644 --- a/apps/sim/app/api/workflows/utils.ts +++ b/apps/sim/app/api/workflows/utils.ts @@ -32,18 +32,21 @@ export function createSuccessResponse(data: any) { * both the /deploy and /status endpoints to ensure consistent results. */ export async function checkNeedsRedeployment(workflowId: string): Promise { - const [[active], normalizedData, [workflowRecord]] = await Promise.all([ - db - .select({ state: workflowDeploymentVersion.state }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) + const [active] = await db + .select({ state: workflowDeploymentVersion.state }) + .from(workflowDeploymentVersion) + .where( + and( + eq(workflowDeploymentVersion.workflowId, workflowId), + eq(workflowDeploymentVersion.isActive, true) ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1), + ) + .orderBy(desc(workflowDeploymentVersion.createdAt)) + .limit(1) + + if (!active?.state) return false + + const [normalizedData, [workflowRecord]] = await Promise.all([ loadWorkflowFromNormalizedTables(workflowId), db .select({ variables: workflow.variables }) @@ -51,8 +54,7 @@ export async function checkNeedsRedeployment(workflowId: string): Promise Date: Mon, 16 Mar 2026 13:58:30 -0700 Subject: [PATCH 10/11] fix(deploy): use client-side comparison for editor header, remove server polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lastSaved-based server polling was triggering API calls on every local store mutation (before socket persistence), wasting requests and checking stale DB state. Revert the editor header to pure client-side hasWorkflowChanged comparison — zero network during editing, instant badge updates. Child workflow badges still use server-side useDeploymentInfo (they don't have Zustand state). Co-Authored-By: Claude Opus 4.6 --- .../panel/components/deploy/deploy.tsx | 10 +-- .../deploy/hooks/use-change-detection.ts | 80 ++----------------- 2 files changed, 7 insertions(+), 83 deletions(-) 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 907b2722173..f146a9dc925 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 @@ -9,7 +9,7 @@ import { useDeployment, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow' -import { useDeployedWorkflowState, useDeploymentInfo } from '@/hooks/queries/deployments' +import { useDeployedWorkflowState } from '@/hooks/queries/deployments' import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -33,12 +33,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP ) const isDeployed = deploymentStatus?.isDeployed || false - // Server-side deployment info (authoritative source for needsRedeployment) - const { data: deploymentInfoData, isLoading: isLoadingDeploymentInfo } = useDeploymentInfo( - activeWorkflowId, - { enabled: isDeployed && !isRegistryLoading } - ) - const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState( activeWorkflowId, @@ -50,8 +44,6 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP workflowId: activeWorkflowId, deployedState, isLoadingDeployedState, - serverNeedsRedeployment: deploymentInfoData?.needsRedeployment, - isServerLoading: isLoadingDeploymentInfo, }) const { isDeploying, handleDeployClick } = useDeployment({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts index e2779b80eaa..7187bf7fc95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-change-detection.ts @@ -1,8 +1,6 @@ -import { useEffect, useMemo, useRef } from 'react' -import { useQueryClient } from '@tanstack/react-query' +import { useMemo } from 'react' import { hasWorkflowChanged } from '@/lib/workflows/comparison' import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' -import { deploymentKeys } from '@/hooks/queries/deployments' import { useVariablesStore } from '@/stores/panel/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -12,34 +10,23 @@ interface UseChangeDetectionProps { workflowId: string | null deployedState: WorkflowState | null isLoadingDeployedState: boolean - serverNeedsRedeployment: boolean | undefined - isServerLoading: boolean } /** * Detects meaningful changes between current workflow state and deployed state. - * - * Uses the server-side needsRedeployment (from useDeploymentInfo) as the - * authoritative signal. The server compares the persisted DB state to the - * deployed version state, which avoids false positives from client-side - * representation differences. - * - * When the workflow store is updated (e.g. after auto-save), the deployment - * info query is invalidated so the server can recheck for changes. + * Performs comparison entirely on the client using hasWorkflowChanged — no API + * calls needed. The deployed state snapshot is fetched once via React Query and + * refreshed after deploy/undeploy/version-activate mutations. */ export function useChangeDetection({ workflowId, deployedState, isLoadingDeployedState, - serverNeedsRedeployment, - isServerLoading, }: UseChangeDetectionProps) { - const queryClient = useQueryClient() const blocks = useWorkflowStore((state) => state.blocks) const edges = useWorkflowStore((state) => state.edges) const loops = useWorkflowStore((state) => state.loops) const parallels = useWorkflowStore((state) => state.parallels) - const lastSaved = useWorkflowStore((state) => state.lastSaved) const subBlockValues = useSubBlockStore((state) => workflowId ? state.workflowValues[workflowId] : null ) @@ -55,50 +42,8 @@ export function useChangeDetection({ return vars }, [workflowId, allVariables]) - // Tracks the lastSaved timestamp at mount to distinguish real saves from initial hydration. - const initialLastSavedRef = useRef(undefined) - const workflowIdRef = useRef(workflowId) - - // Must run before the lastSaved effect to prevent stale-ref invalidation on workflow switch. - useEffect(() => { - workflowIdRef.current = workflowId - initialLastSavedRef.current = undefined - }, [workflowId]) - - useEffect(() => { - if (lastSaved !== undefined && initialLastSavedRef.current === undefined) { - initialLastSavedRef.current = lastSaved - return - } - - if ( - lastSaved === undefined || - initialLastSavedRef.current === undefined || - lastSaved === initialLastSavedRef.current || - !workflowId - ) { - return - } - - initialLastSavedRef.current = lastSaved - - const capturedWorkflowId = workflowId - const timer = setTimeout(() => { - if (workflowIdRef.current !== capturedWorkflowId) return - queryClient.invalidateQueries({ - queryKey: deploymentKeys.info(capturedWorkflowId), - }) - }, 500) - - return () => clearTimeout(timer) - }, [lastSaved, workflowId, queryClient]) - - // Skip expensive state merge when server result is available (the common path). - // Only build currentState for the client-side fallback comparison. - const needsClientFallback = serverNeedsRedeployment === undefined && !isServerLoading - const currentState = useMemo((): WorkflowState | null => { - if (!needsClientFallback || !workflowId || !deployedState) return null + if (!workflowId || !deployedState) return null const mergedBlocks = mergeSubblockStateWithValues(blocks, subBlockValues ?? {}) @@ -110,7 +55,6 @@ export function useChangeDetection({ variables: workflowVariables, } as WorkflowState & { variables: Record } }, [ - needsClientFallback, workflowId, deployedState, blocks, @@ -122,21 +66,9 @@ export function useChangeDetection({ ]) const changeDetected = useMemo(() => { - if (isServerLoading) return false - - if (serverNeedsRedeployment !== undefined) { - return serverNeedsRedeployment - } - if (!currentState || !deployedState || isLoadingDeployedState) return false return hasWorkflowChanged(currentState, deployedState) - }, [ - currentState, - deployedState, - isLoadingDeployedState, - serverNeedsRedeployment, - isServerLoading, - ]) + }, [currentState, deployedState, isLoadingDeployedState]) return { changeDetected } } From f7cfa268b489fa5b9744e2947aac53bb63c52df4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 16 Mar 2026 14:26:28 -0700 Subject: [PATCH 11/11] fix(deploy): suppress transient Update flash during deployed state refetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard change detection on isFetching (not just isLoading) so the comparison is suppressed during background refetches after mutations, preventing a brief Update→Live badge flicker. Co-Authored-By: Claude Opus 4.6 --- .../components/panel/components/deploy/deploy.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 f146a9dc925..b04f2ed5ccd 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 @@ -34,16 +34,17 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP const isDeployed = deploymentStatus?.isDeployed || false const isDeployedStateEnabled = Boolean(activeWorkflowId) && isDeployed && !isRegistryLoading - const { data: deployedStateData, isLoading: isLoadingDeployedState } = useDeployedWorkflowState( - activeWorkflowId, - { enabled: isDeployedStateEnabled } - ) + const { + data: deployedStateData, + isLoading: isLoadingDeployedState, + isFetching: isFetchingDeployedState, + } = useDeployedWorkflowState(activeWorkflowId, { enabled: isDeployedStateEnabled }) const deployedState = isDeployedStateEnabled ? (deployedStateData ?? null) : null const { changeDetected } = useChangeDetection({ workflowId: activeWorkflowId, deployedState, - isLoadingDeployedState, + isLoadingDeployedState: isLoadingDeployedState || isFetchingDeployedState, }) const { isDeploying, handleDeployClick } = useDeployment({