From 363ee594fdee320f877fcc9aad2f2239e453cdd7 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 14 Feb 2026 12:47:06 -0800 Subject: [PATCH] fix(logs): stabilize callbacks and memo-wrap components to eliminate re-render cascade --- .../dashboard/components/status-bar/index.ts | 2 +- .../components/status-bar/status-bar.tsx | 45 ++- .../components/workflows-list/index.ts | 2 +- .../workflows-list/workflows-list.tsx | 6 +- .../logs/components/dashboard/dashboard.tsx | 91 ++++- .../components/log-details/log-details.tsx | 350 +++++++++--------- .../log-row-context-menu.tsx | 5 +- .../notifications/notifications.tsx | 11 +- .../components/logs-toolbar/logs-toolbar.tsx | 6 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 229 ++++++++---- .../components/block/block.tsx | 9 +- .../components/emcn/components/code/code.tsx | 4 +- 12 files changed, 495 insertions(+), 265 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/index.ts index e3f74828b..1e4a78adf 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/index.ts @@ -1,2 +1,2 @@ export type { StatusBarSegment } from './status-bar' -export { default, StatusBar } from './status-bar' +export { StatusBar } from './status-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx index 201d7c97b..568d4179c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/status-bar/status-bar.tsx @@ -8,7 +8,7 @@ export interface StatusBarSegment { timestamp: string } -export function StatusBar({ +function StatusBarInner({ segments, selectedSegmentIndices, onSegmentClick, @@ -127,4 +127,45 @@ export function StatusBar({ ) } -export default memo(StatusBar) +/** + * Custom equality function for StatusBar memo. + * Performs structural comparison of segments array to avoid re-renders + * when poll data returns new object references with identical content. + */ +function areStatusBarPropsEqual( + prev: Parameters[0], + next: Parameters[0] +): boolean { + if (prev.workflowId !== next.workflowId) return false + if (prev.segmentDurationMs !== next.segmentDurationMs) return false + if (prev.preferBelow !== next.preferBelow) return false + + if (prev.selectedSegmentIndices !== next.selectedSegmentIndices) { + if (!prev.selectedSegmentIndices || !next.selectedSegmentIndices) return false + if (prev.selectedSegmentIndices.length !== next.selectedSegmentIndices.length) return false + for (let i = 0; i < prev.selectedSegmentIndices.length; i++) { + if (prev.selectedSegmentIndices[i] !== next.selectedSegmentIndices[i]) return false + } + } + + if (prev.segments !== next.segments) { + if (prev.segments.length !== next.segments.length) return false + for (let i = 0; i < prev.segments.length; i++) { + const ps = prev.segments[i] + const ns = next.segments[i] + if ( + ps.successRate !== ns.successRate || + ps.hasExecutions !== ns.hasExecutions || + ps.totalExecutions !== ns.totalExecutions || + ps.successfulExecutions !== ns.successfulExecutions || + ps.timestamp !== ns.timestamp + ) { + return false + } + } + } + + return true +} + +export const StatusBar = memo(StatusBarInner, areStatusBarPropsEqual) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/index.ts index 22f40a871..cae73033c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/index.ts @@ -1,2 +1,2 @@ export type { WorkflowExecutionItem } from './workflows-list' -export { default, WorkflowsList } from './workflows-list' +export { WorkflowsList } from './workflows-list' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx index 5904bd036..9700f4f8b 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/components/workflows-list/workflows-list.tsx @@ -14,7 +14,7 @@ export interface WorkflowExecutionItem { overallSuccessRate: number } -export function WorkflowsList({ +function WorkflowsListInner({ filteredExecutions, expandedWorkflowId, onToggleWorkflow, @@ -103,7 +103,7 @@ export function WorkflowsList({ >({}) const [lastAnchorIndices, setLastAnchorIndices] = useState>({}) - const barsAreaRef = useRef(null) + const lastAnchorIndicesRef = useRef>({}) const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore() @@ -152,20 +152,79 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) { const expandedWorkflowId = workflowIds.length === 1 ? workflowIds[0] : null - const { executions, aggregateSegments, segmentMs } = useMemo(() => { + const { rawExecutions, aggregateSegments, segmentMs } = useMemo(() => { if (!stats) { - return { executions: [], aggregateSegments: [], segmentMs: 0 } + return { rawExecutions: [], aggregateSegments: [], segmentMs: 0 } } - const workflowExecutions = stats.workflows.map(toWorkflowExecution) - return { - executions: workflowExecutions, + rawExecutions: stats.workflows.map(toWorkflowExecution), aggregateSegments: stats.aggregateSegments, segmentMs: stats.segmentMs, } }, [stats]) + /** + * Stabilize execution objects: reuse previous references for workflows + * whose segment data hasn't structurally changed between polls. + * This prevents cascading re-renders through WorkflowsList → StatusBar. + */ + const prevExecutionsRef = useRef([]) + + const executions = useMemo(() => { + const prevMap = new Map(prevExecutionsRef.current.map((e) => [e.workflowId, e])) + let anyChanged = false + + const result = rawExecutions.map((exec) => { + const prev = prevMap.get(exec.workflowId) + if (!prev) { + anyChanged = true + return exec + } + if ( + prev.overallSuccessRate !== exec.overallSuccessRate || + prev.workflowName !== exec.workflowName || + prev.segments.length !== exec.segments.length + ) { + anyChanged = true + return exec + } + + for (let i = 0; i < prev.segments.length; i++) { + const ps = prev.segments[i] + const ns = exec.segments[i] + if ( + ps.totalExecutions !== ns.totalExecutions || + ps.successfulExecutions !== ns.successfulExecutions || + ps.timestamp !== ns.timestamp || + ps.avgDurationMs !== ns.avgDurationMs || + ps.p50Ms !== ns.p50Ms || + ps.p90Ms !== ns.p90Ms || + ps.p99Ms !== ns.p99Ms + ) { + anyChanged = true + return exec + } + } + + return prev + }) + + if ( + !anyChanged && + result.length === prevExecutionsRef.current.length && + result.every((r, i) => r === prevExecutionsRef.current[i]) + ) { + return prevExecutionsRef.current + } + + return result + }, [rawExecutions]) + + useEffect(() => { + prevExecutionsRef.current = executions + }, [executions]) + const lastExecutionByWorkflow = useMemo(() => { const map = new Map() for (const wf of executions) { @@ -312,6 +371,10 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) { [toggleWorkflowId] ) + useEffect(() => { + lastAnchorIndicesRef.current = lastAnchorIndices + }, [lastAnchorIndices]) + /** * Handles segment click for selecting time segments. * @param workflowId - The workflow containing the segment @@ -361,7 +424,7 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) { } else if (mode === 'range') { setSelectedSegments((prev) => { const currentSegments = prev[workflowId] || [] - const anchor = lastAnchorIndices[workflowId] ?? segmentIndex + const anchor = lastAnchorIndicesRef.current[workflowId] ?? segmentIndex const [start, end] = anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor] const range = Array.from({ length: end - start + 1 }, (_, i) => start + i) @@ -370,12 +433,12 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) { }) } }, - [lastAnchorIndices] + [] ) useEffect(() => { - setSelectedSegments({}) - setLastAnchorIndices({}) + setSelectedSegments((prev) => (Object.keys(prev).length > 0 ? {} : prev)) + setLastAnchorIndices((prev) => (Object.keys(prev).length > 0 ? {} : prev)) }, [stats, timeRange, workflowIds, searchQuery]) if (isLoading) { @@ -493,7 +556,7 @@ export default function Dashboard({ stats, isLoading, error }: DashboardProps) { -
+
) } + +export default memo(DashboardInner) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 43aa334e4..0590021f2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -43,184 +43,199 @@ import { useLogDetailsUIStore } from '@/stores/logs/store' /** * Workflow Output section with code viewer, copy, search, and context menu functionality */ -function WorkflowOutputSection({ output }: { output: Record }) { - const contentRef = useRef(null) - const [copied, setCopied] = useState(false) +const WorkflowOutputSection = memo( + function WorkflowOutputSection({ output }: { output: Record }) { + const contentRef = useRef(null) + const [copied, setCopied] = useState(false) + const copyTimerRef = useRef(null) - // Context menu state - const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) - const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) - const { - isSearchActive, - searchQuery, - setSearchQuery, - matchCount, - currentMatchIndex, - activateSearch, - closeSearch, - goToNextMatch, - goToPreviousMatch, - handleMatchCountChange, - searchInputRef, - } = useCodeViewerFeatures({ contentRef }) + const { + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) - const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) + const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output]) - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - setContextMenuPosition({ x: e.clientX, y: e.clientY }) - setIsContextMenuOpen(true) - }, []) + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + }, []) - const closeContextMenu = useCallback(() => { - setIsContextMenuOpen(false) - }, []) + const closeContextMenu = useCallback(() => { + setIsContextMenuOpen(false) + }, []) - const handleCopy = useCallback(() => { - navigator.clipboard.writeText(jsonString) - setCopied(true) - setTimeout(() => setCopied(false), 1500) - closeContextMenu() - }, [jsonString, closeContextMenu]) + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(jsonString) + setCopied(true) + if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) + copyTimerRef.current = window.setTimeout(() => setCopied(false), 1500) + closeContextMenu() + }, [jsonString, closeContextMenu]) - const handleSearch = useCallback(() => { - activateSearch() - closeContextMenu() - }, [activateSearch, closeContextMenu]) + useEffect(() => { + return () => { + if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current) + } + }, []) - return ( -
-
- - {/* Glass action buttons overlay */} - {!isSearchActive && ( -
- - - - - {copied ? 'Copied' : 'Copy'} - - - - - - Search - + const handleSearch = useCallback(() => { + activateSearch() + closeContextMenu() + }, [activateSearch, closeContextMenu]) + + return ( +
+
+ + {/* Glass action buttons overlay */} + {!isSearchActive && ( +
+ + + + + {copied ? 'Copied' : 'Copy'} + + + + + + Search + +
+ )} +
+ + {/* Search Overlay */} + {isSearchActive && ( +
e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + +
)} + + {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} + {typeof document !== 'undefined' && + createPortal( + + + + Copy + + Search + + , + document.body + )}
- - {/* Search Overlay */} - {isSearchActive && ( -
e.stopPropagation()} - > - setSearchQuery(e.target.value)} - placeholder='Search...' - className='mr-[2px] h-[23px] w-[94px] text-[12px]' - /> - 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' - )} - > - {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} - - - - -
- )} - - {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} - {typeof document !== 'undefined' && - createPortal( - - - - Copy - - Search - - , - document.body - )} -
- ) -} + ) + }, + (prev, next) => JSON.stringify(prev.output) === JSON.stringify(next.output) +) interface LogDetailsProps { /** The log to display details for */ @@ -278,7 +293,6 @@ export const LogDetails = memo(function LogDetails({ return isWorkflowExecutionLog && log?.cost }, [log, isWorkflowExecutionLog]) - // Extract and clean the workflow final output (recursively remove hidden keys for cleaner display) const workflowOutput = useMemo(() => { const executionData = log?.executionData as | { finalOutput?: Record } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index 41deea199..69075d64f 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -1,6 +1,7 @@ 'use client' import type { RefObject } from 'react' +import { memo } from 'react' import { Popover, PopoverAnchor, @@ -29,7 +30,7 @@ interface LogRowContextMenuProps { * Context menu for log rows. * Provides quick actions for copying data, navigation, and filtering. */ -export function LogRowContextMenu({ +export const LogRowContextMenu = memo(function LogRowContextMenu({ isOpen, position, menuRef, @@ -121,4 +122,4 @@ export function LogRowContextMenu({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx index 81bbfa3a2..e1dcc4193 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/components/notifications/notifications.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { createLogger } from '@sim/logger' import { Plus, X } from 'lucide-react' import { @@ -113,7 +113,7 @@ function formatAlertConfigLabel(config: { } } -export function NotificationSettings({ +export const NotificationSettings = memo(function NotificationSettings({ workspaceId, open, onOpenChange, @@ -144,7 +144,7 @@ export function NotificationSettings({ slackChannelId: '', slackChannelName: '', slackAccountId: '', - useAlertRule: false, + alertRule: 'none' as AlertRule, consecutiveFailures: 3, failureRatePercent: 50, @@ -212,7 +212,7 @@ export function NotificationSettings({ slackChannelId: '', slackChannelName: '', slackAccountId: '', - useAlertRule: false, + alertRule: 'none', consecutiveFailures: 3, failureRatePercent: 50, @@ -484,7 +484,6 @@ export function NotificationSettings({ slackChannelId: subscription.slackConfig?.channelId || '', slackChannelName: subscription.slackConfig?.channelName || '', slackAccountId: subscription.slackConfig?.accountId || '', - useAlertRule: !!subscription.alertConfig, alertRule: subscription.alertConfig?.rule || 'none', consecutiveFailures: subscription.alertConfig?.consecutiveFailures || 3, failureRatePercent: subscription.alertConfig?.failureRatePercent || 50, @@ -1289,4 +1288,4 @@ export function NotificationSettings({ ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index 11280214f..9a6897990 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { @@ -149,7 +149,7 @@ function getTriggerIcon( * @param props - The component props * @returns The complete logs toolbar */ -export function LogsToolbar({ +export const LogsToolbar = memo(function LogsToolbar({ viewMode, onViewModeChange, isRefreshing, @@ -749,4 +749,4 @@ export function LogsToolbar({
) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 738332418..c92f29172 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { useParams } from 'next/navigation' @@ -11,6 +11,7 @@ import { hasActiveFilters, } from '@/lib/logs/filters' import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useFolders } from '@/hooks/queries/folders' import { prefetchLogDetail, @@ -21,7 +22,6 @@ import { import { useDebounce } from '@/hooks/use-debounce' import { useFilterStore } from '@/stores/logs/filters/store' import type { WorkflowLog } from '@/stores/logs/filters/types' -import { useUserPermissionsContext } from '../providers/workspace-permissions-provider' import { Dashboard, ExecutionSnapshot, @@ -36,6 +36,38 @@ import { LOG_COLUMN_ORDER, LOG_COLUMNS } from './utils' const LOGS_PER_PAGE = 50 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const +interface LogSelectionState { + selectedLogId: string | null + isSidebarOpen: boolean +} + +type LogSelectionAction = + | { type: 'TOGGLE_LOG'; logId: string } + | { type: 'SELECT_LOG'; logId: string } + | { type: 'CLOSE_SIDEBAR' } + | { type: 'TOGGLE_SIDEBAR' } + +function logSelectionReducer( + state: LogSelectionState, + action: LogSelectionAction +): LogSelectionState { + switch (action.type) { + case 'TOGGLE_LOG': + if (state.selectedLogId === action.logId && state.isSidebarOpen) { + return { selectedLogId: null, isSidebarOpen: false } + } + return { selectedLogId: action.logId, isSidebarOpen: true } + case 'SELECT_LOG': + return { ...state, selectedLogId: action.logId } + case 'CLOSE_SIDEBAR': + return { selectedLogId: null, isSidebarOpen: false } + case 'TOGGLE_SIDEBAR': + return state.selectedLogId ? { ...state, isSidebarOpen: !state.isSidebarOpen } : state + default: + return state + } +} + /** * Logs page component displaying workflow execution history. * Supports filtering, search, live updates, and detailed log inspection. @@ -66,11 +98,13 @@ export default function Logs() { setWorkspaceId(workspaceId) }, [workspaceId, setWorkspaceId]) - const [selectedLogId, setSelectedLogId] = useState(null) - const [isSidebarOpen, setIsSidebarOpen] = useState(false) + const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { + selectedLogId: null, + isSidebarOpen: false, + }) const selectedRowRef = useRef(null) const loaderRef = useRef(null) - const scrollContainerRef = useRef(null) + const isInitialized = useRef(false) const [searchQuery, setSearchQuery] = useState('') @@ -88,6 +122,13 @@ export default function Logs() { const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false) const [isExporting, setIsExporting] = useState(false) const isSearchOpenRef = useRef(false) + const refreshTimersRef = useRef(new Set()) + const logsRef = useRef([]) + const selectedLogIndexRef = useRef(-1) + const selectedLogIdRef = useRef(null) + const logsRefetchRef = useRef<() => void>(() => {}) + const activeLogRefetchRef = useRef<() => void>(() => {}) + const logsQueryRef = useRef({ isFetching: false, hasNextPage: false, fetchNextPage: () => {} }) const [isNotificationSettingsOpen, setIsNotificationSettingsOpen] = useState(false) const userPermissions = useUserPermissionsContext() @@ -180,40 +221,64 @@ export default function Logs() { useFolders(workspaceId) + useEffect(() => { + logsRef.current = logs + }, [logs]) + useEffect(() => { + selectedLogIndexRef.current = selectedLogIndex + }, [selectedLogIndex]) + useEffect(() => { + selectedLogIdRef.current = selectedLogId + }, [selectedLogId]) + useEffect(() => { + logsRefetchRef.current = logsQuery.refetch + }, [logsQuery.refetch]) + useEffect(() => { + activeLogRefetchRef.current = activeLogQuery.refetch + }, [activeLogQuery.refetch]) + useEffect(() => { + logsQueryRef.current = { + isFetching: logsQuery.isFetching, + hasNextPage: logsQuery.hasNextPage ?? false, + fetchNextPage: logsQuery.fetchNextPage, + } + }, [logsQuery.isFetching, logsQuery.hasNextPage, logsQuery.fetchNextPage]) + + useEffect(() => { + const timers = refreshTimersRef.current + return () => { + timers.forEach((id) => window.clearTimeout(id)) + timers.clear() + } + }, []) + useEffect(() => { if (isInitialized.current) { setStoreSearchQuery(debouncedSearchQuery) } }, [debouncedSearchQuery, setStoreSearchQuery]) - const handleLogClick = useCallback( - (log: WorkflowLog) => { - if (selectedLogId === log.id && isSidebarOpen) { - setIsSidebarOpen(false) - setSelectedLogId(null) - return - } - setSelectedLogId(log.id) - setIsSidebarOpen(true) - }, - [selectedLogId, isSidebarOpen] - ) + const handleLogClick = useCallback((log: WorkflowLog) => { + dispatch({ type: 'TOGGLE_LOG', logId: log.id }) + }, []) const handleNavigateNext = useCallback(() => { - if (selectedLogIndex < logs.length - 1) { - setSelectedLogId(logs[selectedLogIndex + 1].id) + const idx = selectedLogIndexRef.current + const currentLogs = logsRef.current + if (idx < currentLogs.length - 1) { + dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id }) } - }, [selectedLogIndex, logs]) + }, []) const handleNavigatePrev = useCallback(() => { - if (selectedLogIndex > 0) { - setSelectedLogId(logs[selectedLogIndex - 1].id) + const idx = selectedLogIndexRef.current + if (idx > 0) { + dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id }) } - }, [selectedLogIndex, logs]) + }, []) const handleCloseSidebar = useCallback(() => { - setIsSidebarOpen(false) - setSelectedLogId(null) + dispatch({ type: 'CLOSE_SIDEBAR' }) }, []) const handleLogContextMenu = useCallback((e: React.MouseEvent, log: WorkflowLog) => { @@ -284,26 +349,39 @@ export default function Logs() { const handleRefresh = useCallback(() => { setIsVisuallyRefreshing(true) - setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS) - logsQuery.refetch() - if (selectedLogId) { - activeLogQuery.refetch() + const timerId = window.setTimeout(() => { + setIsVisuallyRefreshing(false) + refreshTimersRef.current.delete(timerId) + }, REFRESH_SPINNER_DURATION_MS) + refreshTimersRef.current.add(timerId) + logsRefetchRef.current() + if (selectedLogIdRef.current) { + activeLogRefetchRef.current() } - }, [logsQuery, activeLogQuery, selectedLogId]) + }, []) + + const isLiveRef = useRef(isLive) + useEffect(() => { + isLiveRef.current = isLive + }, [isLive]) const handleToggleLive = useCallback(() => { - const newIsLive = !isLive - setIsLive(newIsLive) + const wasLive = isLiveRef.current + setIsLive((prev) => !prev) - if (newIsLive) { + if (!wasLive) { setIsVisuallyRefreshing(true) - setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS) - logsQuery.refetch() - if (selectedLogId) { - activeLogQuery.refetch() + const timerId = window.setTimeout(() => { + setIsVisuallyRefreshing(false) + refreshTimersRef.current.delete(timerId) + }, REFRESH_SPINNER_DURATION_MS) + refreshTimersRef.current.add(timerId) + logsRefetchRef.current() + if (selectedLogIdRef.current) { + activeLogRefetchRef.current() } } - }, [isLive, logsQuery, activeLogQuery, selectedLogId]) + }, []) const prevIsFetchingRef = useRef(logsQuery.isFetching) useEffect(() => { @@ -313,11 +391,15 @@ export default function Logs() { if (isLive && !wasFetching && isFetching) { setIsVisuallyRefreshing(true) - setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS) + const timerId = window.setTimeout(() => { + setIsVisuallyRefreshing(false) + refreshTimersRef.current.delete(timerId) + }, REFRESH_SPINNER_DURATION_MS) + refreshTimersRef.current.add(timerId) } }, [logsQuery.isFetching, isLive]) - const handleExport = async () => { + const handleExport = useCallback(async () => { setIsExporting(true) try { const params = new URLSearchParams() @@ -351,7 +433,17 @@ export default function Logs() { } finally { setIsExporting(false) } - } + }, [ + workspaceId, + level, + triggers, + workflowIds, + folderIds, + timeRange, + startDate, + endDate, + debouncedSearchQuery, + ]) useEffect(() => { if (!isInitialized.current) { @@ -372,41 +464,59 @@ export default function Logs() { }, [initializeFromURL]) const loadMoreLogs = useCallback(() => { - if (!logsQuery.isFetching && logsQuery.hasNextPage) { - logsQuery.fetchNextPage() + const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current + if (!isFetching && hasNextPage) { + fetchNextPage() } - }, [logsQuery]) + }, []) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (isSearchOpenRef.current) return - if (logs.length === 0) return + const currentLogs = logsRef.current + const currentIndex = selectedLogIndexRef.current + if (currentLogs.length === 0) return - if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + if (currentIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault() - setSelectedLogId(logs[0].id) + dispatch({ type: 'SELECT_LOG', logId: currentLogs[0].id }) return } - if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && selectedLogIndex > 0) { + if (e.key === 'ArrowUp' && !e.metaKey && !e.ctrlKey && currentIndex > 0) { e.preventDefault() handleNavigatePrev() } - if (e.key === 'ArrowDown' && !e.metaKey && !e.ctrlKey && selectedLogIndex < logs.length - 1) { + if ( + e.key === 'ArrowDown' && + !e.metaKey && + !e.ctrlKey && + currentIndex < currentLogs.length - 1 + ) { e.preventDefault() handleNavigateNext() } - if (e.key === 'Enter' && selectedLogId) { + if (e.key === 'Enter' && selectedLogIdRef.current) { e.preventDefault() - setIsSidebarOpen(!isSidebarOpen) + dispatch({ type: 'TOGGLE_SIDEBAR' }) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [logs, selectedLogIndex, isSidebarOpen, selectedLogId, handleNavigateNext, handleNavigatePrev]) + }, [handleNavigateNext, handleNavigatePrev]) + + const handleCloseContextMenu = useCallback(() => setContextMenuOpen(false), []) + const handleOpenNotificationSettings = useCallback(() => setIsNotificationSettingsOpen(true), []) + const handleSearchOpenChange = useCallback((open: boolean) => { + isSearchOpenRef.current = open + }, []) + const handleClosePreview = useCallback(() => { + setIsPreviewOpen(false) + setPreviewLogId(null) + }, []) const isDashboardView = viewMode === 'dashboard' @@ -426,12 +536,10 @@ export default function Logs() { onExport={handleExport} canEdit={userPermissions.canEdit} hasLogs={logs.length > 0} - onOpenNotificationSettings={() => setIsNotificationSettingsOpen(true)} + onOpenNotificationSettings={handleOpenNotificationSettings} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} - onSearchOpenChange={(open: boolean) => { - isSearchOpenRef.current = open - }} + onSearchOpenChange={handleSearchOpenChange} />
@@ -473,7 +581,7 @@ export default function Logs() {
{/* Table body - virtualized */} -
+
{logsQuery.isLoading && !logsQuery.data ? (
@@ -536,7 +644,7 @@ export default function Logs() { isOpen={contextMenuOpen} position={contextMenuPosition} menuRef={contextMenuRef} - onClose={() => setContextMenuOpen(false)} + onClose={handleCloseContextMenu} log={contextMenuLog} onCopyExecutionId={handleCopyExecutionId} onOpenWorkflow={handleOpenWorkflow} @@ -553,10 +661,7 @@ export default function Logs() { traceSpans={activeLogQuery.data.executionData?.traceSpans} isModal isOpen={isPreviewOpen} - onClose={() => { - setIsPreviewOpen(false) - setPreviewLogId(null) - }} + onClose={handleClosePreview} /> )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 07b8839dd..a55934a0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -223,7 +223,12 @@ function resolveToolsDisplay( * - Resolves tool names from block registry * - Shows '-' for other selector types that need hydration */ -function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) { +const SubBlockRow = memo(function SubBlockRow({ + title, + value, + subBlock, + rawValue, +}: SubBlockRowProps) { const isPasswordField = subBlock?.password === true const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null @@ -255,7 +260,7 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) { )}
) -} +}) /** * Preview block component for workflow visualization. diff --git a/apps/sim/components/emcn/components/code/code.tsx b/apps/sim/components/emcn/components/code/code.tsx index e0a40846b..a22f56e92 100644 --- a/apps/sim/components/emcn/components/code/code.tsx +++ b/apps/sim/components/emcn/components/code/code.tsx @@ -1008,7 +1008,7 @@ const VirtualizedViewerInner = memo(function VirtualizedViewerInner({ * Non-virtualized code viewer implementation. * Renders all lines directly without windowing. */ -function ViewerInner({ +const ViewerInner = memo(function ViewerInner({ code, showGutter, language, @@ -1181,7 +1181,7 @@ function ViewerInner({ ) -} +}) /** * Readonly code viewer with optional gutter and syntax highlighting.