From dc131b6a34146564003e1b10c17d4815b5a7e9cf Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:56:01 -0700 Subject: [PATCH 1/3] fix(workflow): prevent canvas slowdown cascades --- .../connection-block-selector.tsx | 23 ++- .../components/tool-input/tool-input.tsx | 5 +- .../workflow-search-replace.tsx | 66 ++++---- .../utils/workflow-canvas-helpers.test.ts | 156 ++++++++++++++++++ .../utils/workflow-canvas-helpers.ts | 52 ++++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 39 +++-- .../components/search-modal/utils.test.ts | 12 ++ .../sidebar/components/search-modal/utils.ts | 10 ++ .../stores/workflows/workflow/store.test.ts | 38 +++++ apps/sim/stores/workflows/workflow/store.ts | 11 +- 10 files changed, 352 insertions(+), 60 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 2295f32810c..403123f5732 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -21,6 +21,8 @@ import { import { filterAndCap, GROUP_HEADING_CLASSNAME, + MAX_RESULTS_PER_GROUP, + sliceGroupsToLimit, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { CMDK_ITEM_GAP_CLASS, @@ -139,6 +141,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps([]) + const [browseLimit, setBrowseLimit] = useState(MAX_RESULTS_PER_GROUP) const deferredSearch = useDeferredValue(search) const isSearching = deferredSearch.trim().length > 0 const recentStorageKey = `${RECENT_SELECTION_STORAGE_PREFIX}:${workspaceId}` @@ -260,6 +263,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps availableTools.filter((tool) => !recentSelectionKeys.has(`tool:${tool.id}`)), [availableTools, recentSelectionKeys] ) + const [visibleBrowseBlocks, visibleBrowseTools] = useMemo( + () => sliceGroupsToLimit([browseBlocks, browseTools], browseLimit), + [browseBlocks, browseLimit, browseTools] + ) + const hasMoreBrowseResults = browseLimit < browseBlocks.length + browseTools.length const dispatchSelection = useCallback( (type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => { @@ -480,11 +488,22 @@ export function ConnectionBlockSelector({ id, data }: NodeProps - + + {hasMoreBrowseResults && ( +
+ +
+ )} )} 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 55920dce208..194d1e1dfbc 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 @@ -697,6 +697,7 @@ export const ToolInput = memo(function ToolInput({ const customBlockOverlayVersion = useCustomBlockOverlayVersion() const toolBlocks = useMemo(() => { + if (!open) return [] const allToolBlocks = getAllBlocks().filter(isAgentToolBlock) /* An empty option list means the block declares no selectable operation, so there is nothing to gate — only a wholly denied one leaves the picker. */ @@ -705,7 +706,7 @@ export const ToolInput = memo(function ToolInput({ const { options, denied } = getOperationChoices(block) return options.length === 0 || options.some((option) => !denied.has(option.id)) }) - }, [filterBlocks, customBlockOverlayVersion, getOperationChoices]) + }, [filterBlocks, customBlockOverlayVersion, getOperationChoices, open]) const hasBackfilledRef = useRef(false) useEffect(() => { @@ -1403,6 +1404,7 @@ export const ToolInput = memo(function ToolInput({ * @returns Array of option groups for the combobox component */ const toolGroups = useMemo((): ComboboxOptionGroup[] => { + if (!open) return [] const groups: ComboboxOptionGroup[] = [] // MCP Server drill-down: when navigated into a server, show only its tools @@ -1681,6 +1683,7 @@ export const ToolInput = memo(function ToolInput({ return groups }, [ + open, mcpServerDrilldown, customTools, availableMcpTools, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 90d773f7933..d2d7f466f04 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -110,6 +110,21 @@ function createActiveSearchTarget( } export function WorkflowSearchReplace() { + const { isOpen, open } = useWorkflowSearchReplaceStore( + useShallow((state) => ({ isOpen: state.isOpen, open: state.open })) + ) + + useRegisterGlobalCommands([ + createCommand({ + id: 'open-workflow-search-replace', + handler: open, + }), + ]) + + return isOpen ? : null +} + +function WorkflowSearchReplacePanel() { const params = useParams() const workspaceId = params.workspaceId as string | undefined const routeWorkflowId = params.workflowId as string | undefined @@ -143,26 +158,22 @@ export function WorkflowSearchReplace() { >({}) const { - isOpen, query, replacement: textReplacement, activeMatchId, position, close, - open, setPosition, setQuery, setReplacement, setActiveMatchId, } = useWorkflowSearchReplaceStore( useShallow((state) => ({ - isOpen: state.isOpen, query: state.query, replacement: state.replacement, activeMatchId: state.activeMatchId, position: state.position, close: state.close, - open: state.open, setPosition: state.setPosition, setQuery: state.setQuery, setReplacement: state.setReplacement, @@ -170,11 +181,11 @@ export function WorkflowSearchReplace() { })) ) const prevQueryRef = useRef(query) - const prevIsOpenRef = useRef(false) + const isFirstMatchSyncRef = useRef(true) const afterReplaceIndexRef = useRef(null) - const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen }) - const { data: customTools = [] } = useCustomTools(isOpen && workspaceId ? workspaceId : '') - const { mcpTools } = useMcpTools(isOpen && workspaceId ? workspaceId : '') + const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId }) + const { data: customTools = [] } = useCustomTools(workspaceId ?? '') + const { mcpTools } = useMcpTools(workspaceId ?? '') const mcpToolNamesById = useMemo(() => { const names = new Map() for (const t of mcpTools) { @@ -183,19 +194,6 @@ export function WorkflowSearchReplace() { return names }, [mcpTools]) - useRegisterGlobalCommands([ - createCommand({ - id: 'open-workflow-search-replace', - handler: () => { - open() - requestAnimationFrame(() => { - searchInputRef.current?.focus() - searchInputRef.current?.select() - }) - }, - }), - ]) - const searchBlocks = useMemo( () => getWorkflowSearchBlocks({ @@ -267,10 +265,16 @@ export function WorkflowSearchReplace() { ) useEffect(() => { - if (!isOpen) return searchInputRef.current?.focus() searchInputRef.current?.select() - }, [isOpen]) + }, []) + + useEffect( + () => () => { + usePanelEditorSearchStore.getState().setActiveSearchTarget(null) + }, + [] + ) const panelHeight = isReplaceExpanded ? SEARCH_PANEL_EXPANDED_HEIGHT @@ -288,7 +292,7 @@ export function WorkflowSearchReplace() { }) useFloatBoundarySync({ - isOpen, + isOpen: true, position: actualPosition, width: SEARCH_PANEL_WIDTH, height: panelHeight, @@ -384,14 +388,8 @@ export function WorkflowSearchReplace() { } useEffect(() => { - if (!isOpen) { - prevIsOpenRef.current = false - usePanelEditorSearchStore.getState().setActiveSearchTarget(null) - return - } - - const justOpened = !prevIsOpenRef.current - prevIsOpenRef.current = true + const justOpened = isFirstMatchSyncRef.current + isFirstMatchSyncRef.current = false const queryChanged = prevQueryRef.current !== query prevQueryRef.current = query @@ -422,9 +420,7 @@ export function WorkflowSearchReplace() { usePanelEditorSearchStore .getState() .setActiveSearchTarget(createActiveSearchTarget(activeHydratedMatch, query)) - }, [activeMatchId, handleSelectMatch, hydratedMatches, isOpen, query, setActiveMatchId]) - - if (!isOpen) return null + }, [activeMatchId, handleSelectMatch, hydratedMatches, query, setActiveMatchId]) const handleMoveActiveMatch = (delta: number) => { if (hydratedMatches.length === 0) return diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts index d432a2a9126..5c45ab5c1cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts @@ -3,10 +3,166 @@ */ import { describe, expect, it } from 'vitest' import { + getArrowNavigationDirection, isPositionalTriggerBlock, + reconcileCanvasEdges, + reconcileCanvasNodes, shouldHighlightContainerDropTarget, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' +describe('getArrowNavigationDirection', () => { + it('moves once for a fresh horizontal arrow press', () => { + expect( + getArrowNavigationDirection({ + key: 'ArrowRight', + repeat: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }) + ).toBe(1) + expect( + getArrowNavigationDirection({ + key: 'ArrowLeft', + repeat: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }) + ).toBe(-1) + }) + + it('ignores held-arrow repeat events instead of restarting canvas navigation', () => { + expect( + getArrowNavigationDirection({ + key: 'ArrowRight', + repeat: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }) + ).toBeNull() + }) + + it('ignores modified arrows and unrelated keys', () => { + expect( + getArrowNavigationDirection({ + key: 'ArrowDown', + repeat: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + }) + ).toBeNull() + expect( + getArrowNavigationDirection({ + key: 'Enter', + repeat: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }) + ).toBeNull() + }) +}) + +describe('canvas reference reconciliation', () => { + it('preserves unaffected node references when one block measurement changes', () => { + const currentNodes = [ + { + id: 'block-1', + position: { x: 0, y: 0 }, + data: { name: 'One' }, + height: 100, + selected: true, + }, + { + id: 'block-2', + position: { x: 200, y: 0 }, + data: { name: 'Two' }, + height: 100, + selected: false, + }, + ] + const derivedNodes = [ + { + id: 'block-1', + position: { x: 0, y: 0 }, + data: { name: 'One' }, + height: 120, + }, + { + id: 'block-2', + position: { x: 200, y: 0 }, + data: { name: 'Two' }, + height: 100, + }, + ] + + const reconciled = reconcileCanvasNodes(currentNodes, derivedNodes) + + expect(reconciled).not.toBe(currentNodes) + expect(reconciled[0]).not.toBe(currentNodes[0]) + expect(reconciled[0].selected).toBe(true) + expect(reconciled[1]).toBe(currentNodes[1]) + }) + + it('preserves edge references when a graph refresh changes no edge semantics', () => { + const onDelete = () => {} + const currentEdges = [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + data: { onDelete, isSelected: false }, + }, + ] + const derivedEdges = [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + data: { onDelete, isSelected: false }, + }, + ] + + const reconciled = reconcileCanvasEdges(currentEdges, derivedEdges) + + expect(reconciled).toBe(currentEdges) + expect(reconciled[0]).toBe(currentEdges[0]) + }) + + it('applies derived graph order while retaining unchanged item references', () => { + const currentNodes = [ + { id: 'block-1', position: { x: 0, y: 0 }, data: {}, selected: false }, + { id: 'block-2', position: { x: 100, y: 0 }, data: {}, selected: false }, + ] + const currentEdges = [ + { id: 'edge-1', source: 'block-1', target: 'block-2' }, + { id: 'edge-2', source: 'block-2', target: 'block-1' }, + ] + + const reconciledNodes = reconcileCanvasNodes(currentNodes, [ + { id: 'block-2', position: { x: 100, y: 0 }, data: {} }, + { id: 'block-1', position: { x: 0, y: 0 }, data: {} }, + ]) + const reconciledEdges = reconcileCanvasEdges(currentEdges, [ + { ...currentEdges[1] }, + { ...currentEdges[0] }, + ]) + + expect(reconciledNodes).toEqual([currentNodes[1], currentNodes[0]]) + expect(reconciledEdges).toEqual([currentEdges[1], currentEdges[0]]) + expect(reconciledNodes[0]).toBe(currentNodes[1]) + expect(reconciledEdges[0]).toBe(currentEdges[1]) + }) +}) + describe('isPositionalTriggerBlock', () => { it('returns true for a top-level block with no incoming edges', () => { const block = { id: 'block-1' } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 4c2120940c0..4eb49e5d078 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -1,4 +1,5 @@ import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer' +import { isEqual } from 'es-toolkit' import type { Edge, Node } from 'reactflow' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' @@ -6,6 +7,57 @@ import type { BlockState } from '@/stores/workflows/workflow/types' export const SUBFLOW_DROP_TARGET_CLASS = 'subflow-node-drop-target' +interface ArrowNavigationEvent { + key: string + repeat: boolean + metaKey: boolean + ctrlKey: boolean + altKey: boolean + shiftKey: boolean +} + +export function getArrowNavigationDirection(event: ArrowNavigationEvent): -1 | 1 | null { + if (event.repeat || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return null + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') return 1 + if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') return -1 + return null +} + +function containsDerivedValues(current: T, derived: T): boolean { + return Object.entries(derived).every(([key, value]) => isEqual(current[key as keyof T], value)) +} + +/** Reuses unchanged React Flow node objects while carrying local selection forward. */ +export function reconcileCanvasNodes(currentNodes: Node[], derivedNodes: Node[]): Node[] { + const currentById = new Map(currentNodes.map((node) => [node.id, node])) + let changed = currentNodes.length !== derivedNodes.length + const nextNodes = derivedNodes.map((derivedNode, index) => { + const currentNode = currentById.get(derivedNode.id) + const nextNode = { ...derivedNode, selected: currentNode?.selected ?? false } + if (currentNodes[index]?.id !== derivedNode.id) changed = true + if (currentNode && containsDerivedValues(currentNode, nextNode)) return currentNode + changed = true + return nextNode + }) + + return changed ? nextNodes : currentNodes +} + +/** Reuses unchanged React Flow edge objects after graph-level derivation reruns. */ +export function reconcileCanvasEdges(currentEdges: Edge[], derivedEdges: Edge[]): Edge[] { + const currentById = new Map(currentEdges.map((edge) => [edge.id, edge])) + let changed = currentEdges.length !== derivedEdges.length + const nextEdges = derivedEdges.map((derivedEdge, index) => { + const currentEdge = currentById.get(derivedEdge.id) + if (currentEdges[index]?.id !== derivedEdge.id) changed = true + if (currentEdge && isEqual(currentEdge, derivedEdge)) return currentEdge + changed = true + return derivedEdge + }) + + return changed ? nextEdges : currentEdges +} + /** * Collects all descendant block IDs for container blocks (loop/parallel) in the given set. * Used to treat a nested subflow as one unit when computing boundary edges (e.g. remove-from-subflow). diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 9e05aa2b40d..acdf5ad9020 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -84,6 +84,7 @@ import { computeClampedPositionUpdates, estimateBlockDimensions, filterProtectedBlocks, + getArrowNavigationDirection, getClampedPositionForNode, getDescendantBlockIds, getEdgeSelectionContextId, @@ -94,6 +95,8 @@ import { isEdgeProtected, isInEditableElement, isPositionalTriggerBlock, + reconcileCanvasEdges, + reconcileCanvasNodes, resolveSelectionConflicts, SUBFLOW_DROP_TARGET_CLASS, shouldHighlightContainerDropTarget, @@ -3004,14 +3007,7 @@ const WorkflowContent = React.memo( return } - // Preserve existing selection state - setDisplayNodes((currentNodes) => { - const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) - return derivedNodes.map((node) => ({ - ...node, - selected: selectedIds.has(node.id), - })) - }) + setDisplayNodes((currentNodes) => reconcileCanvasNodes(currentNodes, derivedNodes)) }, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) /** Pans viewport to pending blocks once they have valid dimensions. */ @@ -4561,10 +4557,8 @@ const WorkflowContent = React.memo( if (embedded) return const handleArrowNavigation = (event: KeyboardEvent) => { - if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return - const isNext = event.key === 'ArrowRight' || event.key === 'ArrowDown' - const isPrev = event.key === 'ArrowLeft' || event.key === 'ArrowUp' - if (!isNext && !isPrev) return + const direction = getArrowNavigationDirection(event) + if (direction === null) return const target = event.target as HTMLElement | null if ( @@ -4595,8 +4589,7 @@ const WorkflowContent = React.memo( event.preventDefault() event.stopPropagation() - const nextNode = - ordered[(currentIndex + (isNext ? 1 : -1) + ordered.length) % ordered.length] + const nextNode = ordered[(currentIndex + direction + ordered.length) % ordered.length] setDisplayNodes((currentNodes) => resolveSelectionConflicts( @@ -4646,11 +4639,14 @@ const WorkflowContent = React.memo( ) /** Stable delete handler to avoid creating new function references per edge. */ + const edgeDeleteStateRef = useRef({ edges, blocks }) + edgeDeleteStateRef.current = { edges, blocks } const handleEdgeDelete = useCallback( (edgeId: string) => { + const { edges: currentEdges, blocks: currentBlocks } = edgeDeleteStateRef.current // Prevent removing edges targeting protected blocks - const edge = edges.find((e) => e.id === edgeId) - if (edge && isEdgeProtected(edge, blocks)) { + const edge = currentEdges.find((candidate) => candidate.id === edgeId) + if (edge && isEdgeProtected(edge, currentBlocks)) { toast({ message: 'Cannot remove connections to locked blocks' }) return } @@ -4666,7 +4662,7 @@ const WorkflowContent = React.memo( return next }) }, - [removeEdge, edges, blocks] + [removeEdge] ) /* @@ -4723,13 +4719,14 @@ const WorkflowContent = React.memo( const editorOpenBlockId = usePanelEditorStore((state) => state.currentBlockId) const panelActiveTab = usePanelStore((state) => state.activeTab) + const previousEdgesWithSelectionRef = useRef([]) const edgesWithSelection = useMemo(() => { const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) /* Indexed once: this memo re-runs on every drag frame, and scanning the selection array twice per edge is O(edges x selection) per frame. */ const selectedNodeIdSet = new Set(selectedNodeIds) - return edgesForDisplay.map((edge) => { + const derivedEdges = edgesForDisplay.map((edge) => { const sourceNode = nodeMap.get(edge.source) const targetNode = nodeMap.get(edge.target) const parentLoopId = sourceNode?.parentId || targetNode?.parentId @@ -4780,6 +4777,12 @@ const WorkflowContent = React.memo( }, } }) + const reconciledEdges = reconcileCanvasEdges( + previousEdgesWithSelectionRef.current, + derivedEdges + ) + previousEdgesWithSelectionRef.current = reconciledEdges + return reconciledEdges }, [ edgesForDisplay, displayNodes, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index c5cf1859ee7..b67b09f3f74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -14,8 +14,20 @@ import { scoreActions, scoreAndSort, scoreSectionItems, + sliceGroupsToLimit, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' +describe('sliceGroupsToLimit', () => { + it('bounds an ordered browse catalog across groups without reordering it', () => { + const blocks = Array.from({ length: 40 }, (_, index) => `block-${index}`) + const tools = Array.from({ length: 40 }, (_, index) => `tool-${index}`) + + expect(sliceGroupsToLimit([blocks, tools], 50)).toEqual([blocks, tools.slice(0, 10)]) + expect(blocks).toHaveLength(40) + expect(tools).toHaveLength(40) + }) +}) + describe('getActionGroupLabel', () => { const action = { id: 'test-action', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 5eaa918703b..f0b3405b60f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -35,6 +35,16 @@ export type SearchSection = (typeof SEARCH_SECTIONS)[number] */ export const CANVAS_SECTIONS = ['blocks', 'triggers', 'tools', 'toolOperations'] as const +/** Takes one ordered prefix across adjacent browse groups. */ +export function sliceGroupsToLimit(groups: T[][], limit: number): T[][] { + let remaining = Math.max(0, limit) + return groups.map((group) => { + const visible = group.slice(0, remaining) + remaining -= visible.length + return visible + }) +} + export interface IntegrationSearchItem { id: string name: string diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index a4d086aa1af..8e9c17b2165 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -1666,6 +1666,44 @@ describe('workflow store', () => { }) }) + describe('updateBlockLayoutMetrics', () => { + it('updates only the measured block and skips identical measurements', () => { + addBlock('block-1', 'agent', 'Agent', { x: 0, y: 0 }) + useWorkflowStore.setState({ + edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], + loops: {}, + parallels: {}, + lastSaved: 123, + }) + + const before = useWorkflowStore.getState() + let notifications = 0 + const unsubscribe = useWorkflowStore.subscribe(() => { + notifications += 1 + }) + + before.updateBlockLayoutMetrics('block-1', { width: 320, height: 180 }) + + const afterFirstMeasurement = useWorkflowStore.getState() + expect(afterFirstMeasurement.blocks['block-1'].height).toBe(180) + expect(afterFirstMeasurement.blocks['block-1'].layout).toEqual({ + measuredWidth: 320, + measuredHeight: 180, + }) + expect(afterFirstMeasurement.edges).toBe(before.edges) + expect(afterFirstMeasurement.loops).toBe(before.loops) + expect(afterFirstMeasurement.parallels).toBe(before.parallels) + expect(afterFirstMeasurement.lastSaved).toBe(before.lastSaved) + expect(notifications).toBe(1) + + afterFirstMeasurement.updateBlockLayoutMetrics('block-1', { width: 320, height: 180 }) + + expect(useWorkflowStore.getState()).toBe(afterFirstMeasurement) + expect(notifications).toBe(1) + unsubscribe() + }) + }) + describe('updateBlockName', () => { beforeEach(() => { useWorkflowStore.setState({ diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 797c49eedac..8b918aabfe9 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -966,6 +966,13 @@ export const useWorkflowStore = create()( logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`) return state } + if ( + block.height === dimensions.height && + block.layout?.measuredWidth === dimensions.width && + block.layout?.measuredHeight === dimensions.height + ) { + return state + } return { blocks: { @@ -980,12 +987,8 @@ export const useWorkflowStore = create()( }, }, }, - edges: [...state.edges], - loops: { ...state.loops }, } }) - get().updateLastSaved() - // No sync needed for layout changes, just visual }, updateLoopCount: (loopId: string, count: number) => From 480294a7609af986dc881c61f4481868c0e19ecf Mon Sep 17 00:00:00 2001 From: Justin Blumencranz <96924014+j15z@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:53:32 -0700 Subject: [PATCH 2/3] fix(workflow): make connection picker scrolling seamless --- .../connection-block-selector.tsx | 52 +++++++++++++++---- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx index 403123f5732..9d0fbddcd9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx @@ -1,6 +1,14 @@ 'use client' -import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { Button, cn } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer' @@ -47,6 +55,7 @@ const SELECTOR_ACTION_MENU_RIGHT_INSET = 24 const SELECTOR_ACTION_MENU_AMPLITUDE = 7 const RECENT_SELECTION_LIMIT = 3 const RECENT_SELECTION_STORAGE_PREFIX = 'sim:connection-block-selector:recent' +const BROWSE_PREFETCH_MARGIN_PX = 640 const POPULAR_BLOCK_TYPES = [ 'agent', 'function', @@ -138,6 +147,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps(null) const listRef = useRef(null) + const browseSentinelRef = useRef(null) const [search, setSearch] = useState('') const [selectedValue, setSelectedValue] = useState('') const [recentSelections, setRecentSelections] = useState([]) @@ -269,6 +279,36 @@ export function ConnectionBlockSelector({ id, data }: NodeProps { + const list = listRef.current + const sentinel = browseSentinelRef.current + if (isSearching || !hasMoreBrowseResults || !list || !sentinel) return + + const browseResultCount = browseBlocks.length + browseTools.length + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + startTransition(() => { + setBrowseLimit((current) => Math.min(current + MAX_RESULTS_PER_GROUP, browseResultCount)) + }) + }, + { + root: list, + rootMargin: `0px 0px ${BROWSE_PREFETCH_MARGIN_PX}px 0px`, + } + ) + + observer.observe(sentinel) + return () => observer.disconnect() + }, [browseBlocks.length, browseTools.length, hasMoreBrowseResults, isSearching]) + const dispatchSelection = useCallback( (type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => { window.dispatchEvent( @@ -494,15 +534,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps {hasMoreBrowseResults && ( -
- -
+