improvement(workflow): narrow zustand selectors and optimize log tree builds (#4378)

* improvement(workflow): narrow zustand selectors and optimize log tree builds

- Add useIsCurrentWorkflowExecuting selector; swap broad useCurrentWorkflowExecution in action-bar/chat
- Use useShallow for execution-state subset in useWorkflowExecution
- Drop useCallback-wrapped object selectors in diff-controls and use-block-state (use primitive selectors)
- Stabilize EMPTY_SUBBLOCK_VALUES constant for empty-workflow selector results in api-info-modal, mcp, tag-dropdown
- O(n) Map lookups in buildEntryTree (was O(n^2) filter scans)
- Skip expanded-paths reset when structured-output data is content-stable
- Extract ReactFlow constants out of workflow.tsx into workflow-constants.ts

* fix(workflow): seed structured-output JSON ref and centralize EMPTY_SUBBLOCK_VALUES

- Use null sentinel for prevDataJsonRef and lazily stringify prevDataRef on first compare so the optimization holds on the first stream refresh after mount
- Export EMPTY_SUBBLOCK_VALUES from the subblock store; remove three duplicated module constants

* chore(ci): bump api-validation route baseline to 717

PR #4373 added apps/sim/app/api/table/[tableId]/export/route.ts but didn't
update the audit baseline, so unrelated PRs fail check:api-validation:strict.
Bump baseline to match current route count.
This commit is contained in:
Waleed
2026-05-01 12:16:50 -07:00
committed by GitHub
parent a7a941f1a7
commit 94dd2e1168
16 changed files with 144 additions and 85 deletions
@@ -7,7 +7,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
import { useExecutionStore, useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -114,7 +114,7 @@ export const ActionBar = memo(
)
const { activeWorkflowId } = useWorkflowRegistry()
const { isExecuting } = useCurrentWorkflowExecution()
const isExecuting = useIsCurrentWorkflowExecuting()
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
const userPermissions = useUserPermissionsContext()
const edges = useWorkflowStore((state) => state.edges)
@@ -55,7 +55,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
import type { BlockLog, ExecutionResult } from '@/executor/types'
import { useChatStore } from '@/stores/chat/store'
import { getChatPosition } from '@/stores/chat/utils'
import { useCurrentWorkflowExecution } from '@/stores/execution'
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { useOperationQueue } from '@/stores/operation-queue/store'
import { useTerminalConsoleStore, useWorkflowConsoleEntries } from '@/stores/terminal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -269,7 +269,7 @@ export function Chat() {
const entries = useWorkflowConsoleEntries(
hasConsoleHydrated && typeof activeWorkflowId === 'string' ? activeWorkflowId : undefined
)
const { isExecuting } = useCurrentWorkflowExecution()
const isExecuting = useIsCurrentWorkflowExecuting()
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
const { data: session } = useSession()
const { addToQueue } = useOperationQueue()
@@ -12,21 +12,12 @@ const NOTIFICATION_WIDTH = 240
const NOTIFICATION_GAP = 16
export const DiffControls = memo(function DiffControls() {
const { isDiffReady, hasActiveDiff, acceptChanges, rejectChanges } = useWorkflowDiffStore(
useCallback(
(state) => ({
isDiffReady: state.isDiffReady,
hasActiveDiff: state.hasActiveDiff,
acceptChanges: state.acceptChanges,
rejectChanges: state.rejectChanges,
}),
[]
)
)
const isDiffReady = useWorkflowDiffStore((state) => state.isDiffReady)
const hasActiveDiff = useWorkflowDiffStore((state) => state.hasActiveDiff)
const acceptChanges = useWorkflowDiffStore((state) => state.acceptChanges)
const rejectChanges = useWorkflowDiffStore((state) => state.rejectChanges)
const { activeWorkflowId } = useWorkflowRegistry(
useCallback((state) => ({ activeWorkflowId: state.activeWorkflowId }), [])
)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const allNotifications = useNotificationStore((state) => state.notifications)
const hasVisibleNotifications = useMemo(() => {
@@ -23,7 +23,7 @@ import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deploymen
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'
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
type NormalizedField = InputFormatField & { name: string }
@@ -38,8 +38,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
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 subBlockValues = useSubBlockStore(
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
)
const { data: workflows = {} } = useWorkflowMap(workspaceId)
@@ -27,7 +27,7 @@ import {
type WorkflowMcpServer,
type WorkflowMcpTool,
} from '@/hooks/queries/workflow-mcp-servers'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('McpToolDeploy')
@@ -120,8 +120,8 @@ export function McpDeploy({
return null
}, [blocks])
const subBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
const subBlockValues = useSubBlockStore(
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
)
const inputFormat = useMemo((): NormalizedField[] => {
@@ -33,7 +33,7 @@ import { normalizeName } from '@/executor/constants'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState } from '@/stores/workflows/workflow/types'
@@ -980,8 +980,8 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
return new Set<string>(rawAccessiblePrefixes)
}, [rawAccessiblePrefixes])
const workflowSubBlockValues = useSubBlockStore((state) =>
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
const workflowSubBlockValues = useSubBlockStore(
(state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES
)
const getMergedSubBlocks = useCallback(
@@ -682,6 +682,7 @@ export const StructuredOutput = memo(function StructuredOutput({
computeInitialPaths(data, isError)
)
const prevDataRef = useRef(data)
const prevDataJsonRef = useRef<string | null>(null)
const prevIsErrorRef = useRef(isError)
const internalRef = useRef<HTMLDivElement>(null)
const listRef = useListRef(null)
@@ -712,10 +713,22 @@ export const StructuredOutput = memo(function StructuredOutput({
// Reset expanded paths when data changes
useEffect(() => {
if (prevDataRef.current !== data || prevIsErrorRef.current !== isError) {
if (prevIsErrorRef.current !== isError) {
prevDataRef.current = data
prevIsErrorRef.current = isError
prevDataJsonRef.current = JSON.stringify(data)
setExpandedPaths(computeInitialPaths(data, isError))
return
}
if (prevDataRef.current !== data) {
const newJson = JSON.stringify(data)
const prevJson = prevDataJsonRef.current ?? JSON.stringify(prevDataRef.current)
if (prevJson !== newJson) {
setExpandedPaths(computeInitialPaths(data, isError))
}
prevDataJsonRef.current = newJson
prevDataRef.current = data
}
}, [data, isError])
@@ -366,14 +366,23 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
}
}
const nestedByContainerId = new Map<string, ConsoleEntry[]>()
for (const e of nestedIterationEntries) {
const parent = e.parentIterations?.[0]
if (!parent) continue
const list = nestedByContainerId.get(parent.iterationContainerId)
if (list) {
list.push(e)
} else {
nestedByContainerId.set(parent.iterationContainerId, [e])
}
}
const subflowNodes: EntryNode[] = []
for (const subflowGroup of subflowGroups.values()) {
const { iterationType, iterationContainerId, groups: iterationGroups } = subflowGroup
const nestedForThisSubflow = nestedIterationEntries.filter((e) => {
const parent = e.parentIterations?.[0]
return parent && parent.iterationContainerId === iterationContainerId
})
const nestedForThisSubflow = nestedByContainerId.get(iterationContainerId) ?? []
const allDirectBlocks = iterationGroups.flatMap((g) => g.blocks)
const allRelevantBlocks = [...allDirectBlocks, ...nestedForThisSubflow]
@@ -406,12 +415,21 @@ export function buildEntryTree(entries: ConsoleEntry[], idPrefix = ''): EntryNod
iterationContainerId,
}
const nestedByIteration = new Map<number, ConsoleEntry[]>()
for (const e of nestedForThisSubflow) {
const iterNum = e.parentIterations?.[0]?.iterationCurrent
if (iterNum === undefined) continue
const list = nestedByIteration.get(iterNum)
if (list) {
list.push(e)
} else {
nestedByIteration.set(iterNum, [e])
}
}
const iterationNodes: EntryNode[] = iterationGroups
.map((iterGroup): EntryNode | null => {
const matchingNestedEntries = nestedForThisSubflow.filter((e) => {
const parent = e.parentIterations?.[0]
return parent?.iterationCurrent === iterGroup.iterationCurrent
})
const matchingNestedEntries = nestedByIteration.get(iterGroup.iterationCurrent) ?? []
const strippedNestedEntries: ConsoleEntry[] = matchingNestedEntries.map((e) => ({
...e,
@@ -1,4 +1,3 @@
import { useCallback } from 'react'
import type { DiffStatus } from '@/lib/workflows/diff/types'
import { hasDiffStatus } from '@/lib/workflows/diff/types'
import { useIsBlockActive } from '@/stores/execution'
@@ -54,15 +53,8 @@ export function useBlockState(
: undefined
// Get diff-related data
const { diffAnalysis, isShowingDiff } = useWorkflowDiffStore(
useCallback(
(state) => ({
diffAnalysis: state.diffAnalysis,
isShowingDiff: state.isShowingDiff,
}),
[]
)
)
const diffAnalysis = useWorkflowDiffStore((state) => state.diffAnalysis)
const isShowingDiff = useWorkflowDiffStore((state) => state.isShowingDiff)
const isDeletedBlock = !isShowingDiff && diffAnalysis?.deleted_blocks?.includes(blockId)
@@ -38,7 +38,7 @@ import { subscriptionKeys } from '@/hooks/queries/subscription'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { isExecutionStreamHttpError, useExecutionStream } from '@/hooks/use-execution-stream'
import { WorkflowValidationError } from '@/serializer'
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
import { useNotificationStore } from '@/stores/notifications'
import {
clearExecutionPointer,
@@ -136,8 +136,20 @@ export function useWorkflowExecution() {
variables: s.variables,
}))
)
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
useCurrentWorkflowExecution()
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } = useExecutionStore(
useShallow((state) => {
const exec = activeWorkflowId
? (state.workflowExecutions.get(activeWorkflowId) ?? defaultWorkflowExecutionState)
: defaultWorkflowExecutionState
return {
isExecuting: exec.isExecuting,
isDebugging: exec.isDebugging,
pendingBlocks: exec.pendingBlocks,
executor: exec.executor,
debugContext: exec.debugContext,
}
})
)
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)
const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId)
const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting)
@@ -0,0 +1,35 @@
import type { EdgeTypes, NodeTypes } from 'reactflow'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
/** Custom node types for ReactFlow. */
export const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
/** Custom edge types for ReactFlow. */
export const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge,
}
/** ReactFlow configuration constants. */
export const defaultEdgeOptions = { type: 'custom' } as const
export const reactFlowStyles = [
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
'[&_.react-flow__background]:hidden',
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
export const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
export const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.1 } as const
export const embeddedResizeFitViewOptions = { ...embeddedFitViewOptions, duration: 0 } as const
export const reactFlowProOptions = { hideAttribution: true } as const
@@ -6,10 +6,8 @@ import ReactFlow, {
applyNodeChanges,
ConnectionLineType,
type Edge,
type EdgeTypes,
type Node,
type NodeChange,
type NodeTypes,
ReactFlowProvider,
SelectionMode,
useReactFlow,
@@ -31,18 +29,14 @@ import {
DiffControls,
Notifications,
Panel,
SubflowNodeComponent,
Terminal,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components'
import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu'
import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu'
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
import {
useAutoLayout,
useCanvasContextMenu,
@@ -70,6 +64,16 @@ import {
resolveSelectionConflicts,
validateTriggerPaste,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import {
defaultEdgeOptions,
edgeTypes,
embeddedFitViewOptions,
embeddedResizeFitViewOptions,
nodeTypes,
reactFlowFitViewOptions,
reactFlowProOptions,
reactFlowStyles,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants'
import { useSocket } from '@/app/workspace/providers/socket-provider'
import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock } from '@/executor/constants'
@@ -180,35 +184,6 @@ function syncPanelWithSelection(selectedIds: string[]) {
}
}
/** Custom node types for ReactFlow. */
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
/** Custom edge types for ReactFlow. */
const edgeTypes: EdgeTypes = {
default: WorkflowEdge,
workflowEdge: WorkflowEdge,
}
/** ReactFlow configuration constants. */
const defaultEdgeOptions = { type: 'custom' }
const reactFlowStyles = [
'[&_.react-flow__handle]:!z-[30]',
'[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
'[&_.react-flow__background]:hidden',
'[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const embeddedFitViewOptions = { padding: 0.15, maxZoom: 0.85, minZoom: 0.1 } as const
const embeddedResizeFitViewOptions = { ...embeddedFitViewOptions, duration: 0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
/**
* Map from edge contextId to edge id.
* Context IDs include parent loop info for edges inside loops.
+1
View File
@@ -2,6 +2,7 @@ export {
useCurrentWorkflowExecution,
useExecutionStore,
useIsBlockActive,
useIsCurrentWorkflowExecuting,
useLastRunEdges,
useLastRunPath,
} from './store'
+14
View File
@@ -180,6 +180,20 @@ export function useCurrentWorkflowExecution(): WorkflowExecutionState {
})
}
/**
* Returns whether the active workflow is currently executing.
* More granular than useCurrentWorkflowExecution — only re-renders when
* the isExecuting boolean changes, not on every iteration update during
* parallel-loop runs.
*/
export function useIsCurrentWorkflowExecuting(): boolean {
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
return useExecutionStore((state) => {
if (!activeWorkflowId) return false
return state.workflowExecutions.get(activeWorkflowId)?.isExecuting ?? false
})
}
/**
* Returns whether a specific block is currently active (executing) in the current workflow.
* More granular than useCurrentWorkflowExecution — only re-renders when
+8 -1
View File
@@ -6,11 +6,18 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { populateTriggerFieldsFromConfig } from '@/hooks/use-trigger-config-aggregation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { SubBlockStore } from '@/stores/workflows/subblock/types'
import type { SubBlockStore, SubBlockValue } from '@/stores/workflows/subblock/types'
import { isTriggerValid } from '@/triggers'
const logger = createLogger('SubBlockStore')
/**
* Stable empty fallback for `state.workflowValues[workflowId]` selectors.
* Using a module-level constant avoids returning a fresh `{}` on every
* selector call, which would defeat Zustand's `Object.is` equality.
*/
export const EMPTY_SUBBLOCK_VALUES: Record<string, Record<string, SubBlockValue>> = {}
/**
* SubBlockState stores values for all subblocks in workflows
*
+1
View File
@@ -71,6 +71,7 @@ vi.mock('@/stores/execution/store', () => ({
lastRunEdges: new Map(),
}),
useIsBlockActive: vi.fn().mockReturnValue(false),
useIsCurrentWorkflowExecuting: vi.fn().mockReturnValue(false),
useLastRunPath: vi.fn().mockReturnValue(new Map()),
useLastRunEdges: vi.fn().mockReturnValue(new Map()),
}))