improvement(sidebar): memoize workflow/folder rows for faster tab navigation (#5428)

* 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.

* fix(sidebar): add workspaceId to render-callback deps

renderWorkflowItem/renderFolderSection now pass workspaceId into the rows, so
they must list it as a dependency — otherwise a workspace switch that doesn't
also change workflowId would leave the callbacks closing over a stale
workspaceId (wrong-workspace deletes/exports).
This commit is contained in:
Waleed
2026-07-05 10:59:53 -07:00
committed by GitHub
parent 487166f414
commit 65435f89aa
9 changed files with 110 additions and 57 deletions
@@ -1,13 +1,13 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
import { Lock } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import clsx from 'clsx'
import { ChevronRight, Folder, FolderOpen, MoreHorizontal } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
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'
const logger = createLogger('FolderItem')
interface FolderItemProps {
workspaceId: string
folder: FolderTreeNode
}
export function FolderItem({ folder }: FolderItemProps) {
const { isAnyDragActive, dragDisabled, onFolderClick, onItemDragStart, onItemDragEnd } =
useSidebarListContext()
const params = useParams()
export const FolderItem = memo(function FolderItem({ workspaceId, folder }: FolderItemProps) {
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onFolderClick,
onItemDragStart,
onItemDragEnd,
} = useSidebarListContext()
const router = useRouter()
const workspaceId = params.workspaceId as string
const updateFolderMutation = useUpdateFolder()
const createWorkflowMutation = useCreateWorkflow()
const createFolderMutation = useCreateFolder()
@@ -95,7 +100,7 @@ export function FolderItem({ folder }: FolderItemProps) {
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
folderIds: capturedSelectionRef.current?.folderIds || [],
isActiveWorkflow: (id) => id === params.workflowId,
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
onSuccess: () => setIsDeleteModalOpen(false),
})
@@ -117,10 +122,13 @@ export function FolderItem({ folder }: FolderItemProps) {
hasWorkflows,
handleExportFolder: handleExportThisFolder,
} = useExportFolder({
workspaceId,
folderId: folder.id,
})
const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection()
const { isExporting: isExportingSelection, handleExportSelection } = useExportSelection({
workspaceId,
})
const isExporting = isExportingThisFolder || isExportingSelection
@@ -606,4 +614,4 @@ export function FolderItem({ folder }: FolderItemProps) {
/>
</>
)
}
})
@@ -1,12 +1,11 @@
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { chipVariants, cn } from '@sim/emcn'
import { Lock } from '@sim/emcn/icons'
import clsx from 'clsx'
import { MoreHorizontal } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
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'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
interface WorkflowItemProps {
workspaceId: string
workflow: WorkflowMetadata
active: boolean
}
@@ -55,11 +55,19 @@ interface WorkflowItemProps {
* @param props - Component props
* @returns Workflow item with drag and selection support
*/
export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
const { isAnyDragActive, dragDisabled, onWorkflowClick, onItemDragStart, onItemDragEnd } =
useSidebarListContext()
const params = useParams()
const workspaceId = params.workspaceId as string
export const WorkflowItem = memo(function WorkflowItem({
workspaceId,
workflow,
active,
}: WorkflowItemProps) {
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onItemDragStart,
onItemDragEnd,
} = useSidebarListContext()
const selectedWorkflows = useFolderStore((state) => state.selectedWorkflows)
const updateWorkflowMutation = useUpdateWorkflow()
const userPermissions = useUserPermissionsContext()
@@ -105,7 +113,7 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
useDeleteWorkflow({
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
isActive: (workflowIds) => workflowIds.includes(params.workflowId as string),
isActive: (workflowIds) => workflowIds.includes(activeWorkflowIdRef.current ?? ''),
onSuccess: () => setIsDeleteModalOpen(false),
})
@@ -113,7 +121,7 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
workspaceId,
workflowIds: capturedSelectionRef.current?.workflowIds || [],
folderIds: capturedSelectionRef.current?.folderIds || [],
isActiveWorkflow: (id) => id === params.workflowId,
isActiveWorkflow: (id) => id === activeWorkflowIdRef.current,
onSuccess: () => setIsDeleteModalOpen(false),
})
@@ -136,8 +144,8 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
{ workspaceId }
)
const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow()
const { handleExportSelection } = useExportSelection()
const { handleExportWorkflow: handleExportWorkflows } = useExportWorkflow({ workspaceId })
const { handleExportSelection } = useExportSelection({ workspaceId })
const handleDuplicate = useCallback(() => {
if (!capturedSelectionRef.current) return
@@ -507,4 +515,4 @@ export function WorkflowItem({ workflow, active }: WorkflowItemProps) {
/>
</>
)
}
})
@@ -1,6 +1,6 @@
'use client'
import { memo, useCallback, useEffect, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import clsx from 'clsx'
import { useShallow } from 'zustand/react/shallow'
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
@@ -341,9 +341,14 @@ export const WorkflowList = memo(function WorkflowList({
folderDescendantIds,
})
/** Mirror `workflowId` into a stable ref so the list context stays referentially stable across navigation. */
const activeWorkflowIdRef = useRef(workflowId)
activeWorkflowIdRef.current = workflowId
const listContextValue = useSidebarListContextValue({
isAnyDragActive: isDragging,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick: handleWorkflowClick,
onFolderClick: handleFolderClick,
onItemDragStart: handleDragStart,
@@ -380,13 +385,17 @@ export const WorkflowList = memo(function WorkflowList({
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createWorkflowDragHandlers(workflow.id, folderId)}
>
<WorkflowItem workflow={workflow} active={isWorkflowActive(workflow.id)} />
<WorkflowItem
workspaceId={workspaceId}
workflow={workflow}
active={isWorkflowActive(workflow.id)}
/>
</div>
<DropIndicatorLine show={showAfter} level={level} position='after' />
</div>
)
},
[dropIndicator, isWorkflowActive, createWorkflowDragHandlers]
[workspaceId, dropIndicator, isWorkflowActive, createWorkflowDragHandlers]
)
const renderFolderSection = useCallback(
@@ -445,7 +454,7 @@ export const WorkflowList = memo(function WorkflowList({
style={{ paddingLeft: `${level * TREE_SPACING.INDENT_PER_LEVEL}px` }}
{...createFolderDragHandlers(folder.id, parentFolderId)}
>
<FolderItem folder={folder} />
<FolderItem workspaceId={workspaceId} folder={folder} />
</div>
<DropIndicatorLine show={showAfter} level={level} position='after' />
@@ -471,6 +480,7 @@ export const WorkflowList = memo(function WorkflowList({
)
},
[
workspaceId,
workflowsByFolder,
expandedFolders,
dropIndicator,
@@ -65,7 +65,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
const hoverExpandTimerRef = useRef<number | null>(null)
const lastDragYRef = useRef<number>(0)
const draggedSourceFolderRef = useRef<string | null>(null)
const siblingsCacheRef = useRef<Map<string, SiblingItem[]>>(new Map())
const siblingsCacheRef = useRef<Map<string, SiblingItem[]> | null>(null)
const isDraggingRef = useRef(false)
const params = useParams()
@@ -152,7 +152,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
}, [hoverFolderId, isDragging, expandedFolders, setExpanded])
useEffect(() => {
siblingsCacheRef.current.clear()
siblingsCacheRef.current?.clear()
}, [workspaceId])
const calculateDropPosition = useCallback(
@@ -276,7 +276,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
(folderId: string | null): SiblingItem[] => {
const cacheKey = folderId ?? 'root'
if (!isDraggingRef.current) {
const cached = siblingsCacheRef.current.get(cacheKey)
const cached = siblingsCacheRef.current?.get(cacheKey)
if (cached) return cached
}
@@ -302,7 +302,8 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
].sort(compareSiblingItems)
if (!isDraggingRef.current) {
siblingsCacheRef.current.set(cacheKey, siblings)
const cache = (siblingsCacheRef.current ??= new Map())
cache.set(cacheKey, siblings)
}
return siblings
},
@@ -474,7 +475,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
setDropIndicator(null)
isDraggingRef.current = false
setIsDragging(false)
siblingsCacheRef.current.clear()
siblingsCacheRef.current?.clear()
if (!indicator) return
@@ -614,7 +615,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
const handleDragStart = useCallback((sourceFolderId: string | null) => {
draggedSourceFolderRef.current = sourceFolderId
siblingsCacheRef.current.clear()
siblingsCacheRef.current?.clear()
isDraggingRef.current = true
setIsDragging(true)
}, [])
@@ -626,7 +627,7 @@ export function useDragDrop(options: UseDragDropOptions = {}) {
setDropIndicator(null)
draggedSourceFolderRef.current = null
setHoverFolderId(null)
siblingsCacheRef.current.clear()
siblingsCacheRef.current?.clear()
}, [])
useEffect(() => {
@@ -1,12 +1,18 @@
'use client'
import { createContext, useContext, useMemo } from 'react'
import { createContext, type RefObject, useContext, useMemo } from 'react'
interface SidebarListContextValue {
/** Whether any drag operation is currently in progress */
isAnyDragActive: boolean
/** Whether item dragging is disabled (e.g. viewer permissions) */
dragDisabled: boolean
/**
* Live id of the workflow open in the URL, held in a ref so rows read it at
* click/delete time without subscribing to `useParams` — which would re-render
* every row on each tab navigation and defeat their memoization.
*/
activeWorkflowIdRef: RefObject<string | undefined>
/** Selects a workflow on click (single or shift-range selection) */
onWorkflowClick: (workflowId: string, shiftKey: boolean) => void
/** Selects a folder on modifier-click (shift-range or cmd/ctrl-toggle selection) */
@@ -18,6 +24,7 @@ interface SidebarListContextValue {
}
const noop = () => {}
const noopActiveWorkflowIdRef: RefObject<string | undefined> = { current: undefined }
/**
* Context for sharing list-item interaction handlers and drag state across
@@ -27,6 +34,7 @@ const noop = () => {}
export const SidebarListContext = createContext<SidebarListContextValue>({
isAnyDragActive: false,
dragDisabled: false,
activeWorkflowIdRef: noopActiveWorkflowIdRef,
onWorkflowClick: noop,
onFolderClick: noop,
onItemDragStart: noop,
@@ -55,6 +63,7 @@ export function useSidebarListContextValue(
const {
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
@@ -65,11 +74,20 @@ export function useSidebarListContextValue(
() => ({
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
onItemDragEnd,
}),
[isAnyDragActive, dragDisabled, onWorkflowClick, onFolderClick, onItemDragStart, onItemDragEnd]
[
isAnyDragActive,
dragDisabled,
activeWorkflowIdRef,
onWorkflowClick,
onFolderClick,
onItemDragStart,
onItemDragEnd,
]
)
}
@@ -1,4 +1,4 @@
import { useCallback } from 'react'
import { useCallback, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useFolderStore } from '@/stores/folders/store'
@@ -40,6 +40,17 @@ export function useWorkflowSelection({
}))
)
/**
* Read the click-time anchor values through refs so `handleWorkflowClick` keeps a stable
* identity across tab navigation. `activeWorkflowId` changes on every route change; without
* refs it would churn the shared sidebar list context on each tab switch and defeat the
* memoization of the WorkflowItem/FolderItem rows that consume it.
*/
const workflowIdsRef = useRef(workflowIds)
workflowIdsRef.current = workflowIds
const activeWorkflowIdRef = useRef(activeWorkflowId)
activeWorkflowIdRef.current = activeWorkflowId
/**
* After a workflow selection change, deselect any folder that is an ancestor of a selected
* workflow to prevent ancestor-descendant co-selection.
@@ -68,8 +79,9 @@ export function useWorkflowSelection({
*/
const handleWorkflowClick = useCallback(
(workflowId: string, shiftKey: boolean) => {
const activeWorkflowId = activeWorkflowIdRef.current
if (shiftKey && activeWorkflowId && activeWorkflowId !== workflowId) {
selectRange(workflowIds, activeWorkflowId, workflowId)
selectRange(workflowIdsRef.current, activeWorkflowId, workflowId)
deselectConflictingFolders()
} else if (shiftKey) {
toggleWorkflowSelection(workflowId)
@@ -78,14 +90,7 @@ export function useWorkflowSelection({
selectOnly(workflowId)
}
},
[
workflowIds,
activeWorkflowId,
selectOnly,
selectRange,
toggleWorkflowSelection,
deselectConflictingFolders,
]
[selectOnly, selectRange, toggleWorkflowSelection, deselectConflictingFolders]
)
return {
@@ -1,6 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { getFolderById } from '@/lib/folders/tree'
import {
downloadFile,
@@ -19,6 +18,10 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportFolder')
interface UseExportFolderProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* The folder ID to export
*/
@@ -91,8 +94,7 @@ function collectSubfolders(
/**
* Hook for managing folder export to ZIP.
*/
export function useExportFolder({ folderId, onSuccess }: UseExportFolderProps) {
const { workspaceId } = useParams<{ workspaceId: string }>()
export function useExportFolder({ workspaceId, folderId, onSuccess }: UseExportFolderProps) {
const { data: workflows = {} } = useWorkflowMap(workspaceId)
const { data: folders = {} } = useFolderMap(workspaceId)
const [isExporting, setIsExporting] = useState(false)
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
downloadFile,
exportWorkflowsToZip,
@@ -17,6 +16,10 @@ import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
const logger = createLogger('useExportSelection')
interface UseExportSelectionProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* Optional callback after successful export
*/
@@ -88,11 +91,8 @@ function collectSubfoldersForMultipleFolders(
* Handles mixed selection by collecting all workflows from selected folders
* and combining with directly selected workflows.
*/
export function useExportSelection({ onSuccess }: UseExportSelectionProps = {}) {
export function useExportSelection({ workspaceId, onSuccess }: UseExportSelectionProps) {
const [isExporting, setIsExporting] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const onSuccessRef = useRef(onSuccess)
onSuccessRef.current = onSuccess
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import {
@@ -16,6 +15,10 @@ import { useFolderStore } from '@/stores/folders/store'
const logger = createLogger('useExportWorkflow')
interface UseExportWorkflowProps {
/**
* Active workspace id
*/
workspaceId: string | undefined
/**
* Optional callback after successful export
*/
@@ -25,10 +28,8 @@ interface UseExportWorkflowProps {
/**
* Hook for managing workflow export to JSON or ZIP.
*/
export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
export function useExportWorkflow({ workspaceId, onSuccess }: UseExportWorkflowProps) {
const [isExporting, setIsExporting] = useState(false)
const params = useParams()
const workspaceId = params.workspaceId as string | undefined
const posthog = usePostHog()
const onSuccessRef = useRef(onSuccess)