Skip to content

Commit 251904c

Browse files
committed
improvement(sidebar): memoize workflow/folder rows for faster tab navigation
Switching between workspace tabs re-rendered every workflow and folder row in the sidebar because the rows (and the shared export hooks they call) subscribed to useParams, which re-renders on every navigation. - Wrap WorkflowItem and FolderItem in React.memo (the only un-memoized leaf rows; every sibling row was already memoized). - Decouple the rows from useParams: thread workspaceId as a stable prop from WorkflowList (matching the FileList convention), and expose the live active workflowId through a stable activeWorkflowIdRef on SidebarListContext, read only in delete callbacks — never during render. - Refactor the three shared export hooks (used only by these two rows) to take workspaceId as a param instead of calling useParams internally. - Stabilize handleWorkflowClick's identity via refs so the shared list context no longer changes identity on navigation. - Lazy-init the drag-drop siblings Map ref. On a tab switch only the two rows whose active state flips now re-render.
1 parent 85f80fa commit 251904c

9 files changed

Lines changed: 108 additions & 56 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client'
22

3-
import { useCallback, useMemo, useRef, useState } from 'react'
3+
import { memo, useCallback, useMemo, useRef, useState } from 'react'
44
import { chipVariants, cn } from '@sim/emcn'
55
import { Lock } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { generateId } from '@sim/utils/id'
88
import clsx from 'clsx'
99
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
10-
import { useParams, useRouter } from 'next/navigation'
10+
import { useRouter } from 'next/navigation'
1111
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
1212
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1313
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -49,15 +49,20 @@ import { generateCreativeWorkflowName } from '@/stores/workflows/registry/utils'
4949
const logger = createLogger('FolderItem')
5050

5151
interface FolderItemProps {
52+
workspaceId: string
5253
folder: FolderTreeNode
5354
}
5455

55-
export function FolderItem({ folder }: FolderItemProps) {
56-
const { isAnyDragActive, dragDisabled, onFolderClick, onItemDragStart, onItemDragEnd } =
57-
useSidebarListContext()
58-
const params = useParams()
56+
export const FolderItem = memo(function FolderItem({ workspaceId, folder }: FolderItemProps) {
57+
const {
58+
isAnyDragActive,
59+
dragDisabled,
60+
activeWorkflowIdRef,
61+
onFolderClick,
62+
onItemDragStart,
63+
onItemDragEnd,
64+
} = useSidebarListContext()
5965
const router = useRouter()
60-
const workspaceId = params.workspaceId as string
6166
const updateFolderMutation = useUpdateFolder()
6267
const createWorkflowMutation = useCreateWorkflow()
6368
const createFolderMutation = useCreateFolder()
@@ -95,7 +100,7 @@ export function FolderItem({ folder }: FolderItemProps) {
95100
workspaceId,
96101
workflowIds: capturedSelectionRef.current?.workflowIds || [],
97102
folderIds: capturedSelectionRef.current?.folderIds || [],
98-
isActiveWorkflow: (id) => id === params.workflowId,
103+
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
99104
onSuccess: () => setIsDeleteModalOpen(false),
100105
})
101106

@@ -117,10 +122,13 @@ export function FolderItem({ folder }: FolderItemProps) {
117122
hasWorkflows,
118123
handleExportFolder: handleExportThisFolder,
119124
} = useExportFolder({
125+
workspaceId,
120126
folderId: folder.id,
121127
})
122128

123-
const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection()
129+
const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection({
130+
workspaceId,
131+
})
124132

125133
const isExporting = isExportingThisFolder || isExportingSelection
126134

@@ -606,4 +614,4 @@ export function FolderItem({ folder }: FolderItemProps) {
606614
/>
607615
</>
608616
)
609-
}
617+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx

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

3-
import { useCallback, useMemo, useRef, useState } from 'react'
3+
import { memo, useCallback, useMemo, useRef, useState } from 'react'
44
import { chipVariants, cn } from '@sim/emcn'
55
import { Lock } from '@sim/emcn/icons'
66
import clsx from 'clsx'
77
import { MoreHorizontal } from 'lucide-react'
88
import Link from 'next/link'
9-
import { useParams } from 'next/navigation'
109
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
1110
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1211
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
@@ -43,6 +42,7 @@ import { useFolderStore } from '@/stores/folders/store'
4342
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
4443

4544
interface WorkflowItemProps {
45+
workspaceId: string
4646
workflow: WorkflowMetadata
4747
active: boolean
4848
}
@@ -55,11 +55,19 @@ interface WorkflowItemProps {
5555
* @param props - Component props
5656
* @returns Workflow item with drag and selection support
5757
*/
58-
export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
59-
const { isAnyDragActive, dragDisabled, onWorkflowClick, onItemDragStart, onItemDragEnd } =
60-
useSidebarListContext()
61-
const params = useParams()
62-
const workspaceId = params.workspaceId as string
58+
export const WorkflowItem = memo(function WorkflowItem({
59+
workspaceId,
60+
workflow,
61+
active,
62+
}: WorkflowItemProps) {
63+
const {
64+
isAnyDragActive,
65+
dragDisabled,
66+
activeWorkflowIdRef,
67+
onWorkflowClick,
68+
onItemDragStart,
69+
onItemDragEnd,
70+
} = useSidebarListContext()
6371
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
6472
const updateWorkflowMutation = useUpdateWorkflow()
6573
const userPermissions = useUserPermissionsContext()
@@ -105,15 +113,15 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
105113
useDeleteWorkflow({
106114
workspaceId,
107115
workflowIds: capturedSelectionRef.current?.workflowIds || [],
108-
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
116+
isActive: (workflowIds) => workflowIds.includes(activeWorkflowIdRef.current ?? ''),
109117
onSuccess: () => setIsDeleteModalOpen(false),
110118
})
111119

112120
const { isDeleting: isDeletingSelection, handleDeleteSelection } = useDeleteSelection({
113121
workspaceId,
114122
workflowIds: capturedSelectionRef.current?.workflowIds || [],
115123
folderIds: capturedSelectionRef.current?.folderIds || [],
116-
isActiveWorkflow: (id) => id === params.workflowId,
124+
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
117125
onSuccess: () => setIsDeleteModalOpen(false),
118126
})
119127

@@ -136,8 +144,8 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
136144
{ workspaceId }
137145
)
138146

139-
const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow()
140-
const { handleExportSelection } = useExportSelection()
147+
const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow({ workspaceId })
148+
const { handleExportSelection } = useExportSelection({ workspaceId })
141149

142150
const handleDuplicate = useCallback(() => {
143151
if (!capturedSelectionRef.current) return
@@ -507,4 +515,4 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
507515
/>
508516
</>
509517
)
510-
}
518+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/workflow-list.tsx

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

3-
import { memo, useCallback, useEffect, useMemo } from 'react'
3+
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
44
import clsx from 'clsx'
55
import { useShallow } from 'zustand/react/shallow'
66
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
@@ -341,9 +341,14 @@ export const WorkflowList = memo(function WorkflowList({
341341
folderDescendantIds,
342342
})
343343

344+
/** Mirror `workflowId` into a stable ref so the list context stays referentially stable across navigation. */
345+
const activeWorkflowIdRef = useRef(workflowId)
346+
activeWorkflowIdRef.current = workflowId
347+
344348
const listContextValue = useSidebarListContextValue({
345349
isAnyDragActive: isDragging,
346350
dragDisabled,
351+
activeWorkflowIdRef,
347352
onWorkflowClick: handleWorkflowClick,
348353
onFolderClick: handleFolderClick,
349354
onItemDragStart: handleDragStart,
@@ -380,7 +385,11 @@ export const WorkflowList = memo(function WorkflowList({
380385
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
381386
{...createWorkflowDragHandlers(workflow.id, folderId)}
382387
>
383-
<WorkflowItem workflow={workflow} active={isWorkflowActive(workflow.id)} />
388+
<WorkflowItem
389+
workspaceId={workspaceId}
390+
workflow={workflow}
391+
active={isWorkflowActive(workflow.id)}
392+
/>
384393
</div>
385394
<DropIndicatorLine show={showAfter} level={level} position='after' />
386395
</div>
@@ -445,7 +454,7 @@ export const WorkflowList = memo(function WorkflowList({
445454
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
446455
{...createFolderDragHandlers(folder.id, parentFolderId)}
447456
>
448-
<FolderItem folder={folder} />
457+
<FolderItem workspaceId={workspaceId} folder={folder} />
449458
</div>
450459
<DropIndicatorLine show={showAfter} level={level} position='after' />
451460

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-drag-drop.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
6565
const hoverExpandTimerRef = useRef<number | null>(null)
6666
const lastDragYRef = useRef<number>(0)
6767
const draggedSourceFolderRef = useRef<string | null>(null)
68-
const siblingsCacheRef = useRef<Map<string, SiblingItem[]>>(new Map())
68+
const siblingsCacheRef = useRef<Map<string, SiblingItem[]> | null>(null)
6969
const isDraggingRef = useRef(false)
7070

7171
const params = useParams()
@@ -152,7 +152,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
152152
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
153153

154154
useEffect(() => {
155-
siblingsCacheRef.current.clear()
155+
siblingsCacheRef.current?.clear()
156156
}, [workspaceId])
157157

158158
const calculateDropPosition = useCallback(
@@ -276,7 +276,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
276276
(folderId: string | null): SiblingItem[] => {
277277
const cacheKey = folderId ?? 'root'
278278
if (!isDraggingRef.current) {
279-
const cached = siblingsCacheRef.current.get(cacheKey)
279+
const cached = siblingsCacheRef.current?.get(cacheKey)
280280
if (cached) return cached
281281
}
282282

@@ -302,7 +302,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
302302
].sort(compareSiblingItems)
303303

304304
if (!isDraggingRef.current) {
305-
siblingsCacheRef.current.set(cacheKey, siblings)
305+
const cache = (siblingsCacheRef.current ??= new Map())
306+
cache.set(cacheKey, siblings)
306307
}
307308
return siblings
308309
},
@@ -474,7 +475,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
474475
setDropIndicator(null)
475476
isDraggingRef.current = false
476477
setIsDragging(false)
477-
siblingsCacheRef.current.clear()
478+
siblingsCacheRef.current?.clear()
478479

479480
if (!indicator) return
480481

@@ -614,7 +615,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
614615

615616
const handleDragStart = useCallback((sourceFolderId: string | null) => {
616617
draggedSourceFolderRef.current = sourceFolderId
617-
siblingsCacheRef.current.clear()
618+
siblingsCacheRef.current?.clear()
618619
isDraggingRef.current = true
619620
setIsDragging(true)
620621
}, [])
@@ -626,7 +627,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
626627
setDropIndicator(null)
627628
draggedSourceFolderRef.current = null
628629
setHoverFolderId(null)
629-
siblingsCacheRef.current.clear()
630+
siblingsCacheRef.current?.clear()
630631
}, [])
631632

632633
useEffect(() => {

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-list-context.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
'use client'
22

3-
import { createContext, useContext, useMemo } from 'react'
3+
import { createContext, type RefObject, useContext, useMemo } from 'react'
44

55
interface SidebarListContextValue {
66
/** Whether any drag operation is currently in progress */
77
isAnyDragActive: boolean
88
/** Whether item dragging is disabled (e.g. viewer permissions) */
99
dragDisabled: boolean
10+
/**
11+
* Live id of the workflow open in the URL, held in a ref so rows read it at
12+
* click/delete time without subscribing to `useParams` — which would re-render
13+
* every row on each tab navigation and defeat their memoization.
14+
*/
15+
activeWorkflowIdRef: RefObject<string | undefined>
1016
/** Selects a workflow on click (single or shift-range selection) */
1117
onWorkflowClick: (workflowId: string, shiftKey: boolean) => void
1218
/** Selects a folder on modifier-click (shift-range or cmd/ctrl-toggle selection) */
@@ -18,6 +24,7 @@ interface SidebarListContextValue {
1824
}
1925

2026
const noop = () => {}
27+
const noopActiveWorkflowIdRef: RefObject<string | undefined> = { current: undefined }
2128

2229
/**
2330
* Context for sharing list-item interaction handlers and drag state across
@@ -27,6 +34,7 @@ const noop = () => {}
2734
export const SidebarListContext = createContext<SidebarListContextValue>({
2835
isAnyDragActive: false,
2936
dragDisabled: false,
37+
activeWorkflowIdRef: noopActiveWorkflowIdRef,
3038
onWorkflowClick: noop,
3139
onFolderClick: noop,
3240
onItemDragStart: noop,
@@ -55,6 +63,7 @@ export function useSidebarListContextValue(
5563
const {
5664
isAnyDragActive,
5765
dragDisabled,
66+
activeWorkflowIdRef,
5867
onWorkflowClick,
5968
onFolderClick,
6069
onItemDragStart,
@@ -65,11 +74,20 @@ export function useSidebarListContextValue(
6574
() => ({
6675
isAnyDragActive,
6776
dragDisabled,
77+
activeWorkflowIdRef,
6878
onWorkflowClick,
6979
onFolderClick,
7080
onItemDragStart,
7181
onItemDragEnd,
7282
}),
73-
[isAnyDragActive, dragDisabled, onWorkflowClick, onFolderClick, onItemDragStart, onItemDragEnd]
83+
[
84+
isAnyDragActive,
85+
dragDisabled,
86+
activeWorkflowIdRef,
87+
onWorkflowClick,
88+
onFolderClick,
89+
onItemDragStart,
90+
onItemDragEnd,
91+
]
7492
)
7593
}

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-workflow-selection.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from 'react'
1+
import { useCallback, useRef } from 'react'
22
import { useShallow } from 'zustand/react/shallow'
33
import { useFolderStore } from '@/stores/folders/store'
44

@@ -40,6 +40,17 @@ export function useWorkflowSelection({
4040
}))
4141
)
4242

43+
/**
44+
* Read the click-time anchor values through refs so `handleWorkflowClick` keeps a stable
45+
* identity across tab navigation. `activeWorkflowId` changes on every route change; without
46+
* refs it would churn the shared sidebar list context on each tab switch and defeat the
47+
* memoization of the WorkflowItem/FolderItem rows that consume it.
48+
*/
49+
const workflowIdsRef = useRef(workflowIds)
50+
workflowIdsRef.current = workflowIds
51+
const activeWorkflowIdRef = useRef(activeWorkflowId)
52+
activeWorkflowIdRef.current = activeWorkflowId
53+
4354
/**
4455
* After a workflow selection change, deselect any folder that is an ancestor of a selected
4556
* workflow to prevent ancestor-descendant co-selection.
@@ -68,8 +79,9 @@ export function useWorkflowSelection({
6879
*/
6980
const handleWorkflowClick = useCallback(
7081
(workflowId: string, shiftKey: boolean) => {
82+
const activeWorkflowId = activeWorkflowIdRef.current
7183
if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
72-
selectRange(workflowIds, activeWorkflowId, workflowId)
84+
selectRange(workflowIdsRef.current, activeWorkflowId, workflowId)
7385
deselectConflictingFolders()
7486
} else if (shiftKey) {
7587
toggleWorkflowSelection(workflowId)
@@ -78,14 +90,7 @@ export function useWorkflowSelection({
7890
selectOnly(workflowId)
7991
}
8092
},
81-
[
82-
workflowIds,
83-
activeWorkflowId,
84-
selectOnly,
85-
selectRange,
86-
toggleWorkflowSelection,
87-
deselectConflictingFolders,
88-
]
93+
[selectOnly, selectRange, toggleWorkflowSelection, deselectConflictingFolders]
8994
)
9095

9196
return {

0 commit comments

Comments
 (0)