Skip to content

Commit 8fe5bb9

Browse files
authored
fix(tab-sync): sync between tabs on change (#489)
* fix(tab-sync): sync between tabs on change * refactor: optimize JSON.stringify operations that are redundant
1 parent b55398d commit 8fe5bb9

File tree

2 files changed

+318
-2
lines changed

2 files changed

+318
-2
lines changed

apps/sim/app/w/[id]/workflow.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node'
1818
import { NotificationList } from '@/app/w/[id]/components/notifications/notifications'
1919
import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node'
2020
import { getBlock } from '@/blocks'
21+
import { useTabSync } from '@/hooks/use-tab-sync'
2122
import { useExecutionStore } from '@/stores/execution/store'
2223
import { useNotificationStore } from '@/stores/notifications/store'
2324
import { useVariablesStore } from '@/stores/panel/variables/store'
@@ -97,6 +98,11 @@ function WorkflowContent() {
9798
const { isDebugModeEnabled } = useGeneralStore()
9899
const [dragStartParentId, setDragStartParentId] = useState<string | null>(null)
99100

101+
// Tab synchronization hook - automatically syncs workflow when tab becomes visible
102+
useTabSync({
103+
enabled: true,
104+
})
105+
100106
// Helper function to update a node's parent with proper position calculation
101107
const updateNodeParent = useCallback(
102108
(nodeId: string, newParentId: string | null) => {
@@ -1344,8 +1350,10 @@ function WorkflowContent() {
13441350
<div
13451351
className={`relative h-full w-full flex-1 transition-all duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
13461352
>
1347-
<Panel />
1348-
<NotificationList />
1353+
<div className='fixed top-0 right-0 z-10'>
1354+
<Panel />
1355+
<NotificationList />
1356+
</div>
13491357
<ReactFlow
13501358
nodes={nodes}
13511359
edges={edgesWithSelection}

apps/sim/hooks/use-tab-sync.ts

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
'use client'
2+
3+
import { useCallback, useEffect, useRef } from 'react'
4+
import { createLogger } from '@/lib/logs/console-logger'
5+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
6+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
7+
import { fetchWorkflowsFromDB } from '@/stores/workflows/sync'
8+
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
9+
10+
const logger = createLogger('TabSync')
11+
12+
export interface TabSyncOptions {
13+
/** Whether tab sync is enabled. Default: true */
14+
enabled?: boolean
15+
/** Minimum time in ms between syncs. Default: 2000 */
16+
minSyncInterval?: number
17+
}
18+
19+
/**
20+
* Helper function to normalize blocks for comparison, excluding position data
21+
* This focuses on structural changes rather than movement
22+
*/
23+
function normalizeBlocksForComparison(blocks: Record<string, any>) {
24+
const normalized: Record<string, any> = {}
25+
26+
for (const [id, block] of Object.entries(blocks)) {
27+
normalized[id] = {
28+
...block,
29+
// Exclude position from comparison to avoid movement sync issues
30+
position: undefined,
31+
}
32+
}
33+
34+
return normalized
35+
}
36+
37+
/**
38+
* Hook that automatically syncs the workflow editor when the user switches back to the tab.
39+
* This prevents the "newest write wins" issue by ensuring users always see the latest version.
40+
* Note: This excludes position changes to avoid inconsistent movement syncing.
41+
*/
42+
export function useTabSync(options: TabSyncOptions = {}) {
43+
const {
44+
enabled = true,
45+
minSyncInterval = 2000, // Increased to reduce conflicts
46+
} = options
47+
48+
const lastSyncRef = useRef<number>(0)
49+
const isSyncingRef = useRef<boolean>(false)
50+
const timeoutRefs = useRef<NodeJS.Timeout[]>([])
51+
const { activeWorkflowId } = useWorkflowRegistry()
52+
const workflowStore = useWorkflowStore()
53+
54+
const syncWorkflowEditor = useCallback(async () => {
55+
if (!enabled || !activeWorkflowId || isSyncingRef.current) {
56+
return
57+
}
58+
59+
// Rate limiting - prevent too frequent syncs
60+
const now = Date.now()
61+
if (now - lastSyncRef.current < minSyncInterval) {
62+
logger.debug('Sync skipped due to rate limiting')
63+
return
64+
}
65+
66+
// Prevent concurrent syncs
67+
isSyncingRef.current = true
68+
lastSyncRef.current = now
69+
70+
try {
71+
logger.info('Tab became visible - checking for workflow updates')
72+
73+
// Store current complete workflow state for comparison (excluding positions)
74+
const currentState = {
75+
blocks: { ...workflowStore.blocks },
76+
edges: [...workflowStore.edges],
77+
loops: { ...workflowStore.loops },
78+
parallels: { ...workflowStore.parallels },
79+
lastSaved: workflowStore.lastSaved || 0,
80+
isDeployed: workflowStore.isDeployed,
81+
deployedAt: workflowStore.deployedAt,
82+
needsRedeployment: workflowStore.needsRedeployment,
83+
hasActiveSchedule: workflowStore.hasActiveSchedule,
84+
hasActiveWebhook: workflowStore.hasActiveWebhook,
85+
}
86+
87+
// Wait for any pending writes to complete before fetching
88+
await new Promise((resolve) => setTimeout(resolve, 200))
89+
90+
// Force a fresh fetch from database to ensure we get the absolute latest state
91+
await fetchWorkflowsFromDB()
92+
93+
// Wait a bit more to ensure the fetch has fully completed and localStorage is updated
94+
await new Promise((resolve) => setTimeout(resolve, 100))
95+
96+
// Get the updated workflow from the registry
97+
const updatedWorkflow = useWorkflowRegistry.getState().workflows[activeWorkflowId]
98+
99+
if (!updatedWorkflow) {
100+
logger.warn('Active workflow not found after sync')
101+
return
102+
}
103+
104+
// Load the updated workflow state from localStorage (populated by fetchWorkflowsFromDB)
105+
const workflowStateKey = `workflow-${activeWorkflowId}`
106+
const subBlockValuesKey = `subblock-values-${activeWorkflowId}`
107+
108+
const updatedWorkflowState = localStorage.getItem(workflowStateKey)
109+
const updatedSubBlockValues = localStorage.getItem(subBlockValuesKey)
110+
111+
if (!updatedWorkflowState) {
112+
logger.warn('No updated workflow state found in localStorage')
113+
return
114+
}
115+
116+
const newWorkflowState = JSON.parse(updatedWorkflowState)
117+
const newSubBlockValues = updatedSubBlockValues ? JSON.parse(updatedSubBlockValues) : {}
118+
const newLastSaved = newWorkflowState.lastSaved || 0
119+
120+
// **CRITICAL: Only update if the database version is actually newer**
121+
// This prevents overriding newer local changes with older database state
122+
if (newLastSaved <= currentState.lastSaved) {
123+
logger.debug('Database state is not newer than current state, skipping update', {
124+
currentLastSaved: new Date(currentState.lastSaved).toISOString(),
125+
newLastSaved: new Date(newLastSaved).toISOString(),
126+
})
127+
return
128+
}
129+
130+
// Normalize and stringify once to avoid redundant processing
131+
const currentNormalized = {
132+
blocks: normalizeBlocksForComparison(currentState.blocks),
133+
edges: currentState.edges,
134+
loops: currentState.loops,
135+
parallels: currentState.parallels,
136+
}
137+
138+
const newNormalized = {
139+
blocks: normalizeBlocksForComparison(newWorkflowState.blocks || {}),
140+
edges: newWorkflowState.edges || [],
141+
loops: newWorkflowState.loops || {},
142+
parallels: newWorkflowState.parallels || {},
143+
}
144+
145+
// Cache stringified versions for comparison
146+
const currentStringified = {
147+
full: JSON.stringify(currentNormalized),
148+
blocks: JSON.stringify(currentNormalized.blocks),
149+
edges: JSON.stringify(currentNormalized.edges),
150+
loops: JSON.stringify(currentNormalized.loops),
151+
parallels: JSON.stringify(currentNormalized.parallels),
152+
}
153+
154+
const newStringified = {
155+
full: JSON.stringify(newNormalized),
156+
blocks: JSON.stringify(newNormalized.blocks),
157+
edges: JSON.stringify(newNormalized.edges),
158+
loops: JSON.stringify(newNormalized.loops),
159+
parallels: JSON.stringify(newNormalized.parallels),
160+
}
161+
162+
const hasStructuralChanges = currentStringified.full !== newStringified.full
163+
164+
// Detailed change detection using cached strings
165+
const hasBlockChanges = currentStringified.blocks !== newStringified.blocks
166+
const hasEdgeChanges = currentStringified.edges !== newStringified.edges
167+
const hasLoopChanges = currentStringified.loops !== newStringified.loops
168+
const hasParallelChanges = currentStringified.parallels !== newStringified.parallels
169+
170+
if (hasStructuralChanges) {
171+
logger.info('Newer structural changes detected - updating editor', {
172+
activeWorkflowId,
173+
blocksChanged: hasBlockChanges,
174+
edgesChanged: hasEdgeChanges,
175+
loopsChanged: hasLoopChanges,
176+
parallelsChanged: hasParallelChanges,
177+
currentBlockCount: Object.keys(currentState.blocks).length,
178+
newBlockCount: Object.keys(newWorkflowState.blocks || {}).length,
179+
currentEdgeCount: currentState.edges.length,
180+
newEdgeCount: (newWorkflowState.edges || []).length,
181+
timeDiff: newLastSaved - currentState.lastSaved,
182+
note: 'Positions preserved to avoid movement conflicts',
183+
})
184+
185+
// Merge new structural changes while preserving current positions
186+
const mergedBlocks = { ...(newWorkflowState.blocks || {}) }
187+
188+
// Preserve current positions to avoid movement conflicts
189+
for (const [blockId, currentBlock] of Object.entries(currentState.blocks)) {
190+
if (mergedBlocks[blockId] && currentBlock.position) {
191+
mergedBlocks[blockId] = {
192+
...mergedBlocks[blockId],
193+
position: currentBlock.position, // Keep current position
194+
}
195+
}
196+
}
197+
198+
// Update the workflow store with structural changes but preserved positions
199+
const completeStateUpdate = {
200+
blocks: mergedBlocks,
201+
edges: newWorkflowState.edges || [],
202+
loops: newWorkflowState.loops || {},
203+
parallels: newWorkflowState.parallels || {},
204+
lastSaved: newLastSaved,
205+
isDeployed:
206+
newWorkflowState.isDeployed !== undefined
207+
? newWorkflowState.isDeployed
208+
: currentState.isDeployed,
209+
deployedAt:
210+
newWorkflowState.deployedAt !== undefined
211+
? newWorkflowState.deployedAt
212+
: currentState.deployedAt,
213+
needsRedeployment:
214+
newWorkflowState.needsRedeployment !== undefined
215+
? newWorkflowState.needsRedeployment
216+
: currentState.needsRedeployment,
217+
hasActiveSchedule:
218+
newWorkflowState.hasActiveSchedule !== undefined
219+
? newWorkflowState.hasActiveSchedule
220+
: currentState.hasActiveSchedule,
221+
hasActiveWebhook:
222+
newWorkflowState.hasActiveWebhook !== undefined
223+
? newWorkflowState.hasActiveWebhook
224+
: currentState.hasActiveWebhook,
225+
}
226+
227+
useWorkflowStore.setState(completeStateUpdate)
228+
229+
// Update subblock values
230+
useSubBlockStore.setState((state) => ({
231+
workflowValues: {
232+
...state.workflowValues,
233+
[activeWorkflowId]: newSubBlockValues,
234+
},
235+
}))
236+
237+
logger.info('Workflow editor successfully synced structural changes (positions preserved)')
238+
} else {
239+
logger.debug('No structural changes detected, positions preserved')
240+
}
241+
} catch (error) {
242+
logger.error('Failed to sync workflow editor:', error)
243+
} finally {
244+
// Always release the sync lock
245+
isSyncingRef.current = false
246+
}
247+
}, [
248+
enabled,
249+
activeWorkflowId,
250+
minSyncInterval,
251+
workflowStore.blocks,
252+
workflowStore.edges,
253+
workflowStore.loops,
254+
workflowStore.parallels,
255+
workflowStore.lastSaved,
256+
workflowStore.isDeployed,
257+
workflowStore.deployedAt,
258+
workflowStore.needsRedeployment,
259+
workflowStore.hasActiveSchedule,
260+
workflowStore.hasActiveWebhook,
261+
])
262+
263+
// Handle tab visibility changes
264+
useEffect(() => {
265+
if (!enabled) {
266+
return
267+
}
268+
269+
const handleVisibilityChange = () => {
270+
// Only sync when tab becomes visible (not when it becomes hidden)
271+
if (document.visibilityState === 'visible') {
272+
logger.debug('Tab became visible - triggering structural sync check')
273+
// Use a longer delay to allow any ongoing operations to complete
274+
const timeoutId = setTimeout(() => {
275+
syncWorkflowEditor()
276+
}, 300)
277+
timeoutRefs.current.push(timeoutId)
278+
}
279+
}
280+
281+
// Also handle window focus as a fallback for older browsers
282+
const handleWindowFocus = () => {
283+
logger.debug('Window focused - triggering structural sync check')
284+
// Use a longer delay to allow any ongoing operations to complete
285+
const timeoutId = setTimeout(() => {
286+
syncWorkflowEditor()
287+
}, 300)
288+
timeoutRefs.current.push(timeoutId)
289+
}
290+
291+
document.addEventListener('visibilitychange', handleVisibilityChange)
292+
window.addEventListener('focus', handleWindowFocus)
293+
294+
return () => {
295+
// Clear any pending timeouts to prevent memory leaks
296+
timeoutRefs.current.forEach(clearTimeout)
297+
timeoutRefs.current = []
298+
299+
document.removeEventListener('visibilitychange', handleVisibilityChange)
300+
window.removeEventListener('focus', handleWindowFocus)
301+
}
302+
}, [enabled, syncWorkflowEditor])
303+
304+
// Return the sync function for manual triggering if needed
305+
return {
306+
syncWorkflowEditor,
307+
}
308+
}

0 commit comments

Comments
 (0)