Skip to content

Commit 9a621bc

Browse files
j15zwaleedlatif1
andauthored
fix(workflow): prevent canvas slowdown cascades (#6881)
* fix(workflow): prevent canvas slowdown cascades * fix(workflow): make connection picker scrolling seamless * fix(workflow): correct two regressions in the canvas perf pass Gating `toolBlocks` on the picker's open state also emptied it for the always-visible selected-tool chips, which silently fell through to their `getBlock` fallback — the branch documented as the exception for types hidden from the picker. Only `toolGroups`, where the expensive group build lives, is gated now. Re-invoking the find shortcut while the panel was already open stopped re-selecting the query: `open()` is a no-op when the panel is mounted, so the mount-time focus effect never re-ran. The panel publishes its focus callback so the shortcut can drive it either way. A saturated `slice` also allocated a fresh array once the limit covered a whole group, re-rendering the memoized "All blocks" group on every tools page-in — the frame cost the change set out to remove. Alongside those: fold the two reconcilers into one generic and decide reuse by identity rather than a three-write `changed` flag; record why the node comparison is deliberately asymmetric (React Flow augments node objects in place, so a symmetric `isEqual` would never reuse anything); give the browse pagination its own constant instead of borrowing the search-result cap; drop a redundant clamp and the deps it needed; inline the single-consumer `sliceGroupsToLimit`; and split the bundled ref so the hottest component stops allocating an object per render. --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent e3a4874 commit 9a621bc

8 files changed

Lines changed: 406 additions & 66 deletions

File tree

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

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
'use client'
22

3-
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
3+
import {
4+
startTransition,
5+
useCallback,
6+
useDeferredValue,
7+
useEffect,
8+
useMemo,
9+
useRef,
10+
useState,
11+
} from 'react'
412
import { Button, cn } from '@sim/emcn'
513
import { X } from '@sim/emcn/icons'
614
import { WorkflowBlockBorder, type WorkflowBorderPort } from '@sim/workflow-renderer'
@@ -45,6 +53,8 @@ const SELECTOR_ACTION_MENU_RIGHT_INSET = 24
4553
const SELECTOR_ACTION_MENU_AMPLITUDE = 7
4654
const RECENT_SELECTION_LIMIT = 3
4755
const RECENT_SELECTION_STORAGE_PREFIX = 'sim:connection-block-selector:recent'
56+
const BROWSE_PREFETCH_MARGIN_PX = 640
57+
const BROWSE_PAGE_SIZE = 50
4858
const POPULAR_BLOCK_TYPES = [
4959
'agent',
5060
'function',
@@ -54,6 +64,11 @@ const POPULAR_BLOCK_TYPES = [
5464
'memory',
5565
] as const
5666

67+
/** Ordered prefix that reuses the source array once `count` covers all of it. */
68+
function takePrefix<T>(items: T[], count: number): T[] {
69+
return count >= items.length ? items : items.slice(0, Math.max(0, count))
70+
}
71+
5772
const SELECTOR_PORTS: WorkflowBorderPort[] = [
5873
{
5974
id: 'target',
@@ -136,9 +151,11 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
136151
const posthog = usePostHog()
137152
const inputRef = useRef<HTMLInputElement>(null)
138153
const listRef = useRef<HTMLDivElement>(null)
154+
const browseSentinelRef = useRef<HTMLDivElement>(null)
139155
const [search, setSearch] = useState('')
140156
const [selectedValue, setSelectedValue] = useState('')
141157
const [recentSelections, setRecentSelections] = useState<RecentSelection[]>([])
158+
const [browseLimit, setBrowseLimit] = useState(BROWSE_PAGE_SIZE)
142159
const deferredSearch = useDeferredValue(search)
143160
const isSearching = deferredSearch.trim().length > 0
144161
const recentStorageKey = `${RECENT_SELECTION_STORAGE_PREFIX}:${workspaceId}`
@@ -260,6 +277,42 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
260277
() => availableTools.filter((tool) => !recentSelectionKeys.has(`tool:${tool.id}`)),
261278
[availableTools, recentSelectionKeys]
262279
)
280+
const visibleBrowseBlocks = useMemo(
281+
() => takePrefix(browseBlocks, browseLimit),
282+
[browseBlocks, browseLimit]
283+
)
284+
const visibleBrowseTools = useMemo(
285+
() => takePrefix(browseTools, browseLimit - browseBlocks.length),
286+
[browseBlocks.length, browseLimit, browseTools]
287+
)
288+
const hasMoreBrowseResults = browseLimit < browseBlocks.length + browseTools.length
289+
290+
/**
291+
* Mounting every cmdk item up front caused the frame spike; a manual "show
292+
* more" control would leak that constraint into the UI. Prefetching near the
293+
* viewport keeps scrolling continuous and cmdk's keyboard navigation intact.
294+
*/
295+
useEffect(() => {
296+
const list = listRef.current
297+
const sentinel = browseSentinelRef.current
298+
if (isSearching || !hasMoreBrowseResults || !list || !sentinel) return
299+
300+
const observer = new IntersectionObserver(
301+
([entry]) => {
302+
if (!entry.isIntersecting) return
303+
startTransition(() => {
304+
setBrowseLimit((current) => current + BROWSE_PAGE_SIZE)
305+
})
306+
},
307+
{
308+
root: list,
309+
rootMargin: `0px 0px ${BROWSE_PREFETCH_MARGIN_PX}px 0px`,
310+
}
311+
)
312+
313+
observer.observe(sentinel)
314+
return () => observer.disconnect()
315+
}, [hasMoreBrowseResults, isSearching])
263316

264317
const dispatchSelection = useCallback(
265318
(type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => {
@@ -480,11 +533,14 @@ export function ConnectionBlockSelector({ id, data }: NodeProps<ConnectionBlockS
480533
)}
481534
<BlocksGroup items={popularBlocks} onSelect={handleBlockSelect} heading='Popular' />
482535
<BlocksGroup
483-
items={browseBlocks}
536+
items={visibleBrowseBlocks}
484537
onSelect={handleBlockSelect}
485538
heading='All blocks'
486539
/>
487-
<ToolsGroup items={browseTools} onSelect={handleToolSelect} />
540+
<ToolsGroup items={visibleBrowseTools} onSelect={handleToolSelect} />
541+
{hasMoreBrowseResults && (
542+
<div ref={browseSentinelRef} aria-hidden='true' className='h-px' />
543+
)}
488544
</>
489545
)}
490546
</CommandFadedList>

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,9 @@ function UnsupportedToolBadge({ message }: { message: string }) {
464464
)
465465
}
466466

467+
const EMPTY_COMBOBOX_GROUPS: ComboboxOptionGroup[] = []
468+
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = []
469+
467470
export const ToolInput = memo(function ToolInput({
468471
blockId,
469472
subBlockId,
@@ -1403,6 +1406,7 @@ export const ToolInput = memo(function ToolInput({
14031406
* @returns Array of option groups for the combobox component
14041407
*/
14051408
const toolGroups = useMemo((): ComboboxOptionGroup[] => {
1409+
if (!open) return EMPTY_COMBOBOX_GROUPS
14061410
const groups: ComboboxOptionGroup[] = []
14071411

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

16821686
return groups
16831687
}, [
1688+
open,
16841689
mcpServerDrilldown,
16851690
customTools,
16861691
availableMcpTools,
@@ -1705,7 +1710,7 @@ export const ToolInput = memo(function ToolInput({
17051710
return (
17061711
<div className='w-full space-y-2'>
17071712
<Combobox
1708-
options={[]}
1713+
options={EMPTY_COMBOBOX_OPTIONS}
17091714
groups={toolGroups}
17101715
placeholder='Add tool...'
17111716
/* Every list this picker offers — blocks, operations, MCP and custom

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

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { Button, cn, Input, toast } from '@sim/emcn'
55
import { ChevronDown, ChevronRight, ChevronUp, X } from '@sim/emcn/icons'
66
import { useParams } from 'next/navigation'
@@ -110,6 +110,30 @@ function createActiveSearchTarget(
110110
}
111111

112112
export function WorkflowSearchReplace() {
113+
const { isOpen, open } = useWorkflowSearchReplaceStore(
114+
useShallow((state) => ({ isOpen: state.isOpen, open: state.open }))
115+
)
116+
const focusSearchInputRef = useRef<(() => void) | null>(null)
117+
118+
useRegisterGlobalCommands([
119+
createCommand({
120+
id: 'open-workflow-search-replace',
121+
handler: () => {
122+
open()
123+
focusSearchInputRef.current?.()
124+
},
125+
}),
126+
])
127+
128+
return isOpen ? <WorkflowSearchReplacePanel focusRef={focusSearchInputRef} /> : null
129+
}
130+
131+
interface WorkflowSearchReplacePanelProps {
132+
/** Lets the shortcut re-select the query while the panel is already open. */
133+
focusRef: RefObject<(() => void) | null>
134+
}
135+
136+
function WorkflowSearchReplacePanel({ focusRef }: WorkflowSearchReplacePanelProps) {
113137
const params = useParams()
114138
const workspaceId = params.workspaceId as string | undefined
115139
const routeWorkflowId = params.workflowId as string | undefined
@@ -143,38 +167,33 @@ export function WorkflowSearchReplace() {
143167
>({})
144168

145169
const {
146-
isOpen,
147170
query,
148171
replacement: textReplacement,
149172
activeMatchId,
150173
position,
151174
close,
152-
open,
153175
setPosition,
154176
setQuery,
155177
setReplacement,
156178
setActiveMatchId,
157179
} = useWorkflowSearchReplaceStore(
158180
useShallow((state) => ({
159-
isOpen: state.isOpen,
160181
query: state.query,
161182
replacement: state.replacement,
162183
activeMatchId: state.activeMatchId,
163184
position: state.position,
164185
close: state.close,
165-
open: state.open,
166186
setPosition: state.setPosition,
167187
setQuery: state.setQuery,
168188
setReplacement: state.setReplacement,
169189
setActiveMatchId: state.setActiveMatchId,
170190
}))
171191
)
172-
const prevQueryRef = useRef(query)
173-
const prevIsOpenRef = useRef(false)
192+
const prevQueryRef = useRef<string | null>(null)
174193
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 : '')
194+
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId })
195+
const { data: customTools = [] } = useCustomTools(workspaceId ?? '')
196+
const { mcpTools } = useMcpTools(workspaceId ?? '')
178197
const mcpToolNamesById = useMemo(() => {
179198
const names = new Map<string, string>()
180199
for (const t of mcpTools) {
@@ -183,19 +202,6 @@ export function WorkflowSearchReplace() {
183202
return names
184203
}, [mcpTools])
185204

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-
199205
const searchBlocks = useMemo(
200206
() =>
201207
getWorkflowSearchBlocks({
@@ -267,10 +273,17 @@ export function WorkflowSearchReplace() {
267273
)
268274

269275
useEffect(() => {
270-
if (!isOpen) return
271-
searchInputRef.current?.focus()
272-
searchInputRef.current?.select()
273-
}, [isOpen])
276+
const focusSearchInput = () => {
277+
searchInputRef.current?.focus()
278+
searchInputRef.current?.select()
279+
}
280+
focusSearchInput()
281+
focusRef.current = focusSearchInput
282+
return () => {
283+
focusRef.current = null
284+
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
285+
}
286+
}, [focusRef])
274287

275288
const panelHeight = isReplaceExpanded
276289
? SEARCH_PANEL_EXPANDED_HEIGHT
@@ -288,7 +301,7 @@ export function WorkflowSearchReplace() {
288301
})
289302

290303
useFloatBoundarySync({
291-
isOpen,
304+
isOpen: true,
292305
position: actualPosition,
293306
width: SEARCH_PANEL_WIDTH,
294307
height: panelHeight,
@@ -384,14 +397,6 @@ export function WorkflowSearchReplace() {
384397
}
385398

386399
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
395400
const queryChanged = prevQueryRef.current !== query
396401
prevQueryRef.current = query
397402

@@ -406,7 +411,7 @@ export function WorkflowSearchReplace() {
406411
const replaceIndex = afterReplaceIndexRef.current
407412
afterReplaceIndexRef.current = null
408413

409-
if (queryChanged || justOpened) {
414+
if (queryChanged) {
410415
handleSelectMatch(hydratedMatches[0].id)
411416
} else if (replaceIndex !== null) {
412417
handleSelectMatch(hydratedMatches[Math.min(replaceIndex, hydratedMatches.length - 1)].id)
@@ -422,9 +427,7 @@ export function WorkflowSearchReplace() {
422427
usePanelEditorSearchStore
423428
.getState()
424429
.setActiveSearchTarget(createActiveSearchTarget(activeHydratedMatch, query))
425-
}, [activeMatchId, handleSelectMatch, hydratedMatches, isOpen, query, setActiveMatchId])
426-
427-
if (!isOpen) return null
430+
}, [activeMatchId, handleSelectMatch, hydratedMatches, query, setActiveMatchId])
428431

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

0 commit comments

Comments
 (0)