From a28a51ca7e7133bb117f46e91bb72e02e39f428b Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 13 Jun 2025 00:59:28 -0700 Subject: [PATCH 1/2] fix(tab-sync): sync between tabs on change --- apps/sim/app/w/[id]/workflow.tsx | 12 +- apps/sim/hooks/use-tab-sync.ts | 296 +++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 apps/sim/hooks/use-tab-sync.ts diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index c0684ded3b0..fc703edb7fb 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -18,6 +18,7 @@ import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node' import { getBlock } from '@/blocks' +import { useTabSync } from '@/hooks/use-tab-sync' import { useExecutionStore } from '@/stores/execution/store' import { useNotificationStore } from '@/stores/notifications/store' import { useVariablesStore } from '@/stores/panel/variables/store' @@ -97,6 +98,11 @@ function WorkflowContent() { const { isDebugModeEnabled } = useGeneralStore() const [dragStartParentId, setDragStartParentId] = useState(null) + // Tab synchronization hook - automatically syncs workflow when tab becomes visible + useTabSync({ + enabled: true, + }) + // Helper function to update a node's parent with proper position calculation const updateNodeParent = useCallback( (nodeId: string, newParentId: string | null) => { @@ -1344,8 +1350,10 @@ function WorkflowContent() {
- - +
+ + +
) { + const normalized: Record = {} + + for (const [id, block] of Object.entries(blocks)) { + normalized[id] = { + ...block, + // Exclude position from comparison to avoid movement sync issues + position: undefined, + } + } + + return normalized +} + +/** + * Hook that automatically syncs the workflow editor when the user switches back to the tab. + * This prevents the "newest write wins" issue by ensuring users always see the latest version. + * Note: This excludes position changes to avoid inconsistent movement syncing. + */ +export function useTabSync(options: TabSyncOptions = {}) { + const { + enabled = true, + minSyncInterval = 2000, // Increased to reduce conflicts + } = options + + const lastSyncRef = useRef(0) + const isSyncingRef = useRef(false) + const timeoutRefs = useRef([]) + const { activeWorkflowId } = useWorkflowRegistry() + const workflowStore = useWorkflowStore() + + const syncWorkflowEditor = useCallback(async () => { + if (!enabled || !activeWorkflowId || isSyncingRef.current) { + return + } + + // Rate limiting - prevent too frequent syncs + const now = Date.now() + if (now - lastSyncRef.current < minSyncInterval) { + logger.debug('Sync skipped due to rate limiting') + return + } + + // Prevent concurrent syncs + isSyncingRef.current = true + lastSyncRef.current = now + + try { + logger.info('Tab became visible - checking for workflow updates') + + // Store current complete workflow state for comparison (excluding positions) + const currentState = { + blocks: { ...workflowStore.blocks }, + edges: [...workflowStore.edges], + loops: { ...workflowStore.loops }, + parallels: { ...workflowStore.parallels }, + lastSaved: workflowStore.lastSaved || 0, + isDeployed: workflowStore.isDeployed, + deployedAt: workflowStore.deployedAt, + needsRedeployment: workflowStore.needsRedeployment, + hasActiveSchedule: workflowStore.hasActiveSchedule, + hasActiveWebhook: workflowStore.hasActiveWebhook, + } + + // Wait for any pending writes to complete before fetching + await new Promise((resolve) => setTimeout(resolve, 200)) + + // Force a fresh fetch from database to ensure we get the absolute latest state + await fetchWorkflowsFromDB() + + // Wait a bit more to ensure the fetch has fully completed and localStorage is updated + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Get the updated workflow from the registry + const updatedWorkflow = useWorkflowRegistry.getState().workflows[activeWorkflowId] + + if (!updatedWorkflow) { + logger.warn('Active workflow not found after sync') + return + } + + // Load the updated workflow state from localStorage (populated by fetchWorkflowsFromDB) + const workflowStateKey = `workflow-${activeWorkflowId}` + const subBlockValuesKey = `subblock-values-${activeWorkflowId}` + + const updatedWorkflowState = localStorage.getItem(workflowStateKey) + const updatedSubBlockValues = localStorage.getItem(subBlockValuesKey) + + if (!updatedWorkflowState) { + logger.warn('No updated workflow state found in localStorage') + return + } + + const newWorkflowState = JSON.parse(updatedWorkflowState) + const newSubBlockValues = updatedSubBlockValues ? JSON.parse(updatedSubBlockValues) : {} + const newLastSaved = newWorkflowState.lastSaved || 0 + + // **CRITICAL: Only update if the database version is actually newer** + // This prevents overriding newer local changes with older database state + if (newLastSaved <= currentState.lastSaved) { + logger.debug('Database state is not newer than current state, skipping update', { + currentLastSaved: new Date(currentState.lastSaved).toISOString(), + newLastSaved: new Date(newLastSaved).toISOString(), + }) + return + } + + // Structural comparison - exclude positions to avoid movement sync issues + const currentStateStr = JSON.stringify({ + blocks: normalizeBlocksForComparison(currentState.blocks), + edges: currentState.edges, + loops: currentState.loops, + parallels: currentState.parallels, + }) + + const newStateStr = JSON.stringify({ + blocks: normalizeBlocksForComparison(newWorkflowState.blocks || {}), + edges: newWorkflowState.edges || [], + loops: newWorkflowState.loops || {}, + parallels: newWorkflowState.parallels || {}, + }) + + const hasStructuralChanges = currentStateStr !== newStateStr + + // More detailed change detection for logging (also excluding positions) + const hasBlockChanges = + JSON.stringify(normalizeBlocksForComparison(currentState.blocks)) !== + JSON.stringify(normalizeBlocksForComparison(newWorkflowState.blocks || {})) + const hasEdgeChanges = + JSON.stringify(currentState.edges) !== JSON.stringify(newWorkflowState.edges || []) + const hasLoopChanges = + JSON.stringify(currentState.loops) !== JSON.stringify(newWorkflowState.loops || {}) + const hasParallelChanges = + JSON.stringify(currentState.parallels) !== JSON.stringify(newWorkflowState.parallels || {}) + + if (hasStructuralChanges) { + logger.info('Newer structural changes detected - updating editor', { + activeWorkflowId, + blocksChanged: hasBlockChanges, + edgesChanged: hasEdgeChanges, + loopsChanged: hasLoopChanges, + parallelsChanged: hasParallelChanges, + currentBlockCount: Object.keys(currentState.blocks).length, + newBlockCount: Object.keys(newWorkflowState.blocks || {}).length, + currentEdgeCount: currentState.edges.length, + newEdgeCount: (newWorkflowState.edges || []).length, + timeDiff: newLastSaved - currentState.lastSaved, + note: 'Positions preserved to avoid movement conflicts', + }) + + // Merge new structural changes while preserving current positions + const mergedBlocks = { ...(newWorkflowState.blocks || {}) } + + // Preserve current positions to avoid movement conflicts + for (const [blockId, currentBlock] of Object.entries(currentState.blocks)) { + if (mergedBlocks[blockId] && currentBlock.position) { + mergedBlocks[blockId] = { + ...mergedBlocks[blockId], + position: currentBlock.position, // Keep current position + } + } + } + + // Update the workflow store with structural changes but preserved positions + const completeStateUpdate = { + blocks: mergedBlocks, + edges: newWorkflowState.edges || [], + loops: newWorkflowState.loops || {}, + parallels: newWorkflowState.parallels || {}, + lastSaved: newLastSaved, + isDeployed: + newWorkflowState.isDeployed !== undefined + ? newWorkflowState.isDeployed + : currentState.isDeployed, + deployedAt: + newWorkflowState.deployedAt !== undefined + ? newWorkflowState.deployedAt + : currentState.deployedAt, + needsRedeployment: + newWorkflowState.needsRedeployment !== undefined + ? newWorkflowState.needsRedeployment + : currentState.needsRedeployment, + hasActiveSchedule: + newWorkflowState.hasActiveSchedule !== undefined + ? newWorkflowState.hasActiveSchedule + : currentState.hasActiveSchedule, + hasActiveWebhook: + newWorkflowState.hasActiveWebhook !== undefined + ? newWorkflowState.hasActiveWebhook + : currentState.hasActiveWebhook, + } + + useWorkflowStore.setState(completeStateUpdate) + + // Update subblock values + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: newSubBlockValues, + }, + })) + + logger.info('Workflow editor successfully synced structural changes (positions preserved)') + } else { + logger.debug('No structural changes detected, positions preserved') + } + } catch (error) { + logger.error('Failed to sync workflow editor:', error) + } finally { + // Always release the sync lock + isSyncingRef.current = false + } + }, [ + enabled, + activeWorkflowId, + minSyncInterval, + workflowStore.blocks, + workflowStore.edges, + workflowStore.loops, + workflowStore.parallels, + workflowStore.lastSaved, + workflowStore.isDeployed, + workflowStore.deployedAt, + workflowStore.needsRedeployment, + workflowStore.hasActiveSchedule, + workflowStore.hasActiveWebhook, + ]) + + // Handle tab visibility changes + useEffect(() => { + if (!enabled) { + return + } + + const handleVisibilityChange = () => { + // Only sync when tab becomes visible (not when it becomes hidden) + if (document.visibilityState === 'visible') { + logger.debug('Tab became visible - triggering structural sync check') + // Use a longer delay to allow any ongoing operations to complete + const timeoutId = setTimeout(() => { + syncWorkflowEditor() + }, 300) + timeoutRefs.current.push(timeoutId) + } + } + + // Also handle window focus as a fallback for older browsers + const handleWindowFocus = () => { + logger.debug('Window focused - triggering structural sync check') + // Use a longer delay to allow any ongoing operations to complete + const timeoutId = setTimeout(() => { + syncWorkflowEditor() + }, 300) + timeoutRefs.current.push(timeoutId) + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('focus', handleWindowFocus) + + return () => { + // Clear any pending timeouts to prevent memory leaks + timeoutRefs.current.forEach(clearTimeout) + timeoutRefs.current = [] + + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('focus', handleWindowFocus) + } + }, [enabled, syncWorkflowEditor]) + + // Return the sync function for manual triggering if needed + return { + syncWorkflowEditor, + } +} From 7020aa3feda1d2e0d42b961caa10a0886b46066c Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 13 Jun 2025 01:07:25 -0700 Subject: [PATCH 2/2] refactor: optimize JSON.stringify operations that are redundant --- apps/sim/hooks/use-tab-sync.ts | 48 +++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/sim/hooks/use-tab-sync.ts b/apps/sim/hooks/use-tab-sync.ts index 688c0bdac38..561479f25f5 100644 --- a/apps/sim/hooks/use-tab-sync.ts +++ b/apps/sim/hooks/use-tab-sync.ts @@ -127,33 +127,45 @@ export function useTabSync(options: TabSyncOptions = {}) { return } - // Structural comparison - exclude positions to avoid movement sync issues - const currentStateStr = JSON.stringify({ + // Normalize and stringify once to avoid redundant processing + const currentNormalized = { blocks: normalizeBlocksForComparison(currentState.blocks), edges: currentState.edges, loops: currentState.loops, parallels: currentState.parallels, - }) + } - const newStateStr = JSON.stringify({ + const newNormalized = { blocks: normalizeBlocksForComparison(newWorkflowState.blocks || {}), edges: newWorkflowState.edges || [], loops: newWorkflowState.loops || {}, parallels: newWorkflowState.parallels || {}, - }) - - const hasStructuralChanges = currentStateStr !== newStateStr - - // More detailed change detection for logging (also excluding positions) - const hasBlockChanges = - JSON.stringify(normalizeBlocksForComparison(currentState.blocks)) !== - JSON.stringify(normalizeBlocksForComparison(newWorkflowState.blocks || {})) - const hasEdgeChanges = - JSON.stringify(currentState.edges) !== JSON.stringify(newWorkflowState.edges || []) - const hasLoopChanges = - JSON.stringify(currentState.loops) !== JSON.stringify(newWorkflowState.loops || {}) - const hasParallelChanges = - JSON.stringify(currentState.parallels) !== JSON.stringify(newWorkflowState.parallels || {}) + } + + // Cache stringified versions for comparison + const currentStringified = { + full: JSON.stringify(currentNormalized), + blocks: JSON.stringify(currentNormalized.blocks), + edges: JSON.stringify(currentNormalized.edges), + loops: JSON.stringify(currentNormalized.loops), + parallels: JSON.stringify(currentNormalized.parallels), + } + + const newStringified = { + full: JSON.stringify(newNormalized), + blocks: JSON.stringify(newNormalized.blocks), + edges: JSON.stringify(newNormalized.edges), + loops: JSON.stringify(newNormalized.loops), + parallels: JSON.stringify(newNormalized.parallels), + } + + const hasStructuralChanges = currentStringified.full !== newStringified.full + + // Detailed change detection using cached strings + const hasBlockChanges = currentStringified.blocks !== newStringified.blocks + const hasEdgeChanges = currentStringified.edges !== newStringified.edges + const hasLoopChanges = currentStringified.loops !== newStringified.loops + const hasParallelChanges = currentStringified.parallels !== newStringified.parallels if (hasStructuralChanges) { logger.info('Newer structural changes detected - updating editor', {