Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix: memoize validation selector to prevent mutation and unnecessary …
…recomputation

- Use individual selectors for blocks/edges/loops/parallels with useShallow
- Memoize validation result with useMemo, only recomputing when deps change
- Pass shallow copies of state to validateWorkflowState to prevent any
  internal mutation from affecting Zustand store state

Addresses Bugbot review feedback for #3579
  • Loading branch information
guoyangzhen committed Mar 15, 2026
commit 945b207aad2a134915f7774dd5f1bbf8faccfe5c
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ArrowUp, Lock, Square, Unlock } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
Expand Down Expand Up @@ -357,16 +357,25 @@ export const Panel = memo(function Panel() {
// Compute run button state
const canRun = userPermissions.canRead // Running only requires read permissions
const isLoadingPermissions = userPermissions.isLoading
const hasValidationErrors = useWorkflowStore((state) => {
if (Object.keys(state.blocks).length === 0) return false
// Memoize workflow structure to avoid re-validating on every store change
// (e.g. block dragging at 60fps, text input, etc.)
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 hasValidationErrors = useMemo(() => {
if (Object.keys(blocks).length === 0) return false
// Pass shallow copies to validateWorkflowState to prevent any
// internal mutation from affecting Zustand store state
const result = validateWorkflowState({
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
blocks: { ...blocks },
edges: [...edges],
loops: { ...(loops || {}) },
parallels: { ...(parallels || {}) },
})
return !result.valid
})
}, [blocks, edges, loops, parallels])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deploy button not disabled when validation errors exist

Medium Severity

The new hasValidationErrors value only feeds into isWorkflowBlockedisButtonDisabled, which is wired to the Run button. The Deploy component doesn't receive hasValidationErrors as a prop and its own isDisabled (isDeploying || !canDeploy || isEmpty) doesn't account for validation errors. Users can still click Deploy on an invalid workflow. The backend will reject it, but the UI-layer prevention described in the PR is missing for the Deploy button.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keyboard shortcut bypasses validation-disabled Run button

Medium Severity

The run-workflow keyboard shortcut handler (Mod+Enter) only checks isExecuting before calling runWorkflow(). It does not check hasValidationErrors or isButtonDisabled. Previously this didn't matter because hasValidationErrors was hardcoded to false. Now that it reflects real validation state, users can bypass the disabled Run button entirely by pressing the keyboard shortcut, running invalid workflows the UI is supposed to prevent.

Additional Locations (1)
Fix in Cursor Fix in Web

const isWorkflowBlocked = isExecuting || hasValidationErrors
const isButtonDisabled = !isExecuting && (isWorkflowBlocked || (!canRun && !isLoadingPermissions))

Expand Down