Skip to content

Commit dc131b6

Browse files
committed
fix(workflow): prevent canvas slowdown cascades
1 parent 9864f5c commit dc131b6

10 files changed

Lines changed: 352 additions & 60 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
import {
2222
filterAndCap,
2323
GROUP_HEADING_CLASSNAME,
24+
MAX_RESULTS_PER_GROUP,
25+
sliceGroupsToLimit,
2426
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils'
2527
import {
2628
CMDK_ITEM_GAP_CLASS,
@@ -139,6 +141,7 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
139141
const [search, setSearch] = useState('')
140142
const [selectedValue, setSelectedValue] = useState('')
141143
const [recentSelections, setRecentSelections] = useState<RecentSelection[]>([])
144+
const [browseLimit, setBrowseLimit] = useState(MAX_RESULTS_PER_GROUP)
142145
const deferredSearch = useDeferredValue(search)
143146
const isSearching = deferredSearch.trim().length > 0
144147
const recentStorageKey = `${RECENT_SELECTION_STORAGE_PREFIX}:${workspaceId}`
@@ -260,6 +263,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
260263
() => availableTools.filter((tool) => !recentSelectionKeys.has(`tool:${tool.id}`)),
261264
[availableTools, recentSelectionKeys]
262265
)
266+
const [visibleBrowseBlocks, visibleBrowseTools] = useMemo(
267+
() => sliceGroupsToLimit([browseBlocks, browseTools], browseLimit),
268+
[browseBlocks, browseLimit, browseTools]
269+
)
270+
const hasMoreBrowseResults = browseLimit < browseBlocks.length + browseTools.length
263271

264272
const dispatchSelection = useCallback(
265273
(type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => {
@@ -480,11 +488,22 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
480488
)}
481489
<BlocksGroup items={popularBlocks} onSelect={handleBlockSelect} heading='Popular' />
482490
<BlocksGroup
483-
items={browseBlocks}
491+
items={visibleBrowseBlocks}
484492
onSelect={handleBlockSelect}
485493
heading='All blocks'
486494
/>
487-
<ToolsGroup items={browseTools} onSelect={handleToolSelect} />
495+
<ToolsGroup items={visibleBrowseTools} onSelect={handleToolSelect} />
496+
{hasMoreBrowseResults && (
497+
<div className='px-2 py-1.5'>
498+
<Button
499+
variant='ghost'
500+
className='nodrag nopan h-8 w-full text-[var(--text-secondary)]'
501+
onClick={() => setBrowseLimit((current) => current + MAX_RESULTS_PER_GROUP)}
502+
>
503+
Show more
504+
</Button>
505+
</div>
506+
)}
488507
</>
489508
)}
490509
</CommandFadedList>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,7 @@ export const ToolInput = memo(function ToolInput({
697697

698698
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
699699
const toolBlocks = useMemo(() => {
700+
if (!open) return []
700701
const allToolBlocks = getAllBlocks().filter(isAgentToolBlock)
701702
/* An empty option list means the block declares no selectable operation, so
702703
there is nothing to gate — only a wholly denied one leaves the picker. */
@@ -705,7 +706,7 @@ export const ToolInput = memo(function ToolInput({
705706
const { options, denied } = getOperationChoices(block)
706707
return options.length === 0 || options.some((option) => !denied.has(option.id))
707708
})
708-
}, [filterBlocks, customBlockOverlayVersion, getOperationChoices])
709+
}, [filterBlocks, customBlockOverlayVersion, getOperationChoices, open])
709710

710711
const hasBackfilledRef = useRef(false)
711712
useEffect(() => {
@@ -1403,6 +1404,7 @@ export const ToolInput = memo(function ToolInput({
14031404
* @returns Array of option groups for the combobox component
14041405
*/
14051406
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
1407+
if (!open) return []
14061408
const groups: ComboboxOptionGroup[] = []
14071409

14081410
// MCP Server drill-down: when navigated into a server, show only its tools
@@ -1681,6 +1683,7 @@ export const ToolInput = memo(function ToolInput({
16811683

16821684
return groups
16831685
}, [
1686+
open,
16841687
mcpServerDrilldown,
16851688
customTools,
16861689
availableMcpTools,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,21 @@ function createActiveSearchTarget(
110110
}
111111

112112
export function WorkflowSearchReplace() {
113+
const { isOpen, open } = useWorkflowSearchReplaceStore(
114+
useShallow((state) => ({ isOpen: state.isOpen, open: state.open }))
115+
)
116+
117+
useRegisterGlobalCommands([
118+
createCommand({
119+
id: 'open-workflow-search-replace',
120+
handler: open,
121+
}),
122+
])
123+
124+
return isOpen ? <WorkflowSearchReplacePanel /> : null
125+
}
126+
127+
function WorkflowSearchReplacePanel() {
113128
const params = useParams()
114129
const workspaceId = params.workspaceId as string | undefined
115130
const routeWorkflowId = params.workflowId as string | undefined
@@ -143,38 +158,34 @@ export function WorkflowSearchReplace() {
143158
>({})
144159

145160
const {
146-
isOpen,
147161
query,
148162
replacement: textReplacement,
149163
activeMatchId,
150164
position,
151165
close,
152-
open,
153166
setPosition,
154167
setQuery,
155168
setReplacement,
156169
setActiveMatchId,
157170
} = useWorkflowSearchReplaceStore(
158171
useShallow((state) => ({
159-
isOpen: state.isOpen,
160172
query: state.query,
161173
replacement: state.replacement,
162174
activeMatchId: state.activeMatchId,
163175
position: state.position,
164176
close: state.close,
165-
open: state.open,
166177
setPosition: state.setPosition,
167178
setQuery: state.setQuery,
168179
setReplacement: state.setReplacement,
169180
setActiveMatchId: state.setActiveMatchId,
170181
}))
171182
)
172183
const prevQueryRef = useRef(query)
173-
const prevIsOpenRef = useRef(false)
184+
const isFirstMatchSyncRef = useRef(true)
174185
const afterReplaceIndexRef = useRef<number | null>(null)
175-
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen })
176-
const { data: customTools = [] } = useCustomTools(isOpen && workspaceId ? workspaceId : '')
177-
const { mcpTools } = useMcpTools(isOpen && workspaceId ? workspaceId : '')
186+
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId })
187+
const { data: customTools = [] } = useCustomTools(workspaceId ?? '')
188+
const { mcpTools } = useMcpTools(workspaceId ?? '')
178189
const mcpToolNamesById = useMemo(() => {
179190
const names = new Map<string, string>()
180191
for (const t of mcpTools) {
@@ -183,19 +194,6 @@ export function WorkflowSearchReplace() {
183194
return names
184195
}, [mcpTools])
185196

186-
useRegisterGlobalCommands([
187-
createCommand({
188-
id: 'open-workflow-search-replace',
189-
handler: () => {
190-
open()
191-
requestAnimationFrame(() => {
192-
searchInputRef.current?.focus()
193-
searchInputRef.current?.select()
194-
})
195-
},
196-
}),
197-
])
198-
199197
const searchBlocks = useMemo(
200198
() =>
201199
getWorkflowSearchBlocks({
@@ -267,10 +265,16 @@ export function WorkflowSearchReplace() {
267265
)
268266

269267
useEffect(() => {
270-
if (!isOpen) return
271268
searchInputRef.current?.focus()
272269
searchInputRef.current?.select()
273-
}, [isOpen])
270+
}, [])
271+
272+
useEffect(
273+
() => () => {
274+
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
275+
},
276+
[]
277+
)
274278

275279
const panelHeight = isReplaceExpanded
276280
? SEARCH_PANEL_EXPANDED_HEIGHT
@@ -288,7 +292,7 @@ export function WorkflowSearchReplace() {
288292
})
289293

290294
useFloatBoundarySync({
291-
isOpen,
295+
isOpen: true,
292296
position: actualPosition,
293297
width: SEARCH_PANEL_WIDTH,
294298
height: panelHeight,
@@ -384,14 +388,8 @@ export function WorkflowSearchReplace() {
384388
}
385389

386390
useEffect(() => {
387-
if (!isOpen) {
388-
prevIsOpenRef.current = false
389-
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
390-
return
391-
}
392-
393-
const justOpened = !prevIsOpenRef.current
394-
prevIsOpenRef.current = true
391+
const justOpened = isFirstMatchSyncRef.current
392+
isFirstMatchSyncRef.current = false
395393
const queryChanged = prevQueryRef.current !== query
396394
prevQueryRef.current = query
397395

@@ -422,9 +420,7 @@ export function WorkflowSearchReplace() {
422420
usePanelEditorSearchStore
423421
.getState()
424422
.setActiveSearchTarget(createActiveSearchTarget(activeHydratedMatch, query))
425-
}, [activeMatchId, handleSelectMatch, hydratedMatches, isOpen, query, setActiveMatchId])
426-
427-
if (!isOpen) return null
423+
}, [activeMatchId, handleSelectMatch, hydratedMatches, query, setActiveMatchId])
428424

429425
const handleMoveActiveMatch = (delta: number) => {
430426
if (hydratedMatches.length === 0) return

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,166 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
getArrowNavigationDirection,
67
isPositionalTriggerBlock,
8+
reconcileCanvasEdges,
9+
reconcileCanvasNodes,
710
shouldHighlightContainerDropTarget,
811
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers'
912

13+
describe('getArrowNavigationDirection', () => {
14+
it('moves once for a fresh horizontal arrow press', () => {
15+
expect(
16+
getArrowNavigationDirection({
17+
key: 'ArrowRight',
18+
repeat: false,
19+
metaKey: false,
20+
ctrlKey: false,
21+
altKey: false,
22+
shiftKey: false,
23+
})
24+
).toBe(1)
25+
expect(
26+
getArrowNavigationDirection({
27+
key: 'ArrowLeft',
28+
repeat: false,
29+
metaKey: false,
30+
ctrlKey: false,
31+
altKey: false,
32+
shiftKey: false,
33+
})
34+
).toBe(-1)
35+
})
36+
37+
it('ignores held-arrow repeat events instead of restarting canvas navigation', () => {
38+
expect(
39+
getArrowNavigationDirection({
40+
key: 'ArrowRight',
41+
repeat: true,
42+
metaKey: false,
43+
ctrlKey: false,
44+
altKey: false,
45+
shiftKey: false,
46+
})
47+
).toBeNull()
48+
})
49+
50+
it('ignores modified arrows and unrelated keys', () => {
51+
expect(
52+
getArrowNavigationDirection({
53+
key: 'ArrowDown',
54+
repeat: false,
55+
metaKey: false,
56+
ctrlKey: false,
57+
altKey: false,
58+
shiftKey: true,
59+
})
60+
).toBeNull()
61+
expect(
62+
getArrowNavigationDirection({
63+
key: 'Enter',
64+
repeat: false,
65+
metaKey: false,
66+
ctrlKey: false,
67+
altKey: false,
68+
shiftKey: false,
69+
})
70+
).toBeNull()
71+
})
72+
})
73+
74+
describe('canvas reference reconciliation', () => {
75+
it('preserves unaffected node references when one block measurement changes', () => {
76+
const currentNodes = [
77+
{
78+
id: 'block-1',
79+
position: { x: 0, y: 0 },
80+
data: { name: 'One' },
81+
height: 100,
82+
selected: true,
83+
},
84+
{
85+
id: 'block-2',
86+
position: { x: 200, y: 0 },
87+
data: { name: 'Two' },
88+
height: 100,
89+
selected: false,
90+
},
91+
]
92+
const derivedNodes = [
93+
{
94+
id: 'block-1',
95+
position: { x: 0, y: 0 },
96+
data: { name: 'One' },
97+
height: 120,
98+
},
99+
{
100+
id: 'block-2',
101+
position: { x: 200, y: 0 },
102+
data: { name: 'Two' },
103+
height: 100,
104+
},
105+
]
106+
107+
const reconciled = reconcileCanvasNodes(currentNodes, derivedNodes)
108+
109+
expect(reconciled).not.toBe(currentNodes)
110+
expect(reconciled[0]).not.toBe(currentNodes[0])
111+
expect(reconciled[0].selected).toBe(true)
112+
expect(reconciled[1]).toBe(currentNodes[1])
113+
})
114+
115+
it('preserves edge references when a graph refresh changes no edge semantics', () => {
116+
const onDelete = () => {}
117+
const currentEdges = [
118+
{
119+
id: 'edge-1',
120+
source: 'block-1',
121+
target: 'block-2',
122+
data: { onDelete, isSelected: false },
123+
},
124+
]
125+
const derivedEdges = [
126+
{
127+
id: 'edge-1',
128+
source: 'block-1',
129+
target: 'block-2',
130+
data: { onDelete, isSelected: false },
131+
},
132+
]
133+
134+
const reconciled = reconcileCanvasEdges(currentEdges, derivedEdges)
135+
136+
expect(reconciled).toBe(currentEdges)
137+
expect(reconciled[0]).toBe(currentEdges[0])
138+
})
139+
140+
it('applies derived graph order while retaining unchanged item references', () => {
141+
const currentNodes = [
142+
{ id: 'block-1', position: { x: 0, y: 0 }, data: {}, selected: false },
143+
{ id: 'block-2', position: { x: 100, y: 0 }, data: {}, selected: false },
144+
]
145+
const currentEdges = [
146+
{ id: 'edge-1', source: 'block-1', target: 'block-2' },
147+
{ id: 'edge-2', source: 'block-2', target: 'block-1' },
148+
]
149+
150+
const reconciledNodes = reconcileCanvasNodes(currentNodes, [
151+
{ id: 'block-2', position: { x: 100, y: 0 }, data: {} },
152+
{ id: 'block-1', position: { x: 0, y: 0 }, data: {} },
153+
])
154+
const reconciledEdges = reconcileCanvasEdges(currentEdges, [
155+
{ ...currentEdges[1] },
156+
{ ...currentEdges[0] },
157+
])
158+
159+
expect(reconciledNodes).toEqual([currentNodes[1], currentNodes[0]])
160+
expect(reconciledEdges).toEqual([currentEdges[1], currentEdges[0]])
161+
expect(reconciledNodes[0]).toBe(currentNodes[1])
162+
expect(reconciledEdges[0]).toBe(currentEdges[1])
163+
})
164+
})
165+
10166
describe('isPositionalTriggerBlock', () => {
11167
it('returns true for a top-level block with no incoming edges', () => {
12168
const block = { id: 'block-1' }

0 commit comments

Comments
 (0)