Skip to content

Commit df264d9

Browse files
improvement(workflows): replace Zustand workflow sync with React Query as single source of truth (#3860)
* improvement(workflows): replace Zustand workflow sync with React Query as single source of truth * fix(workflows): address PR review feedback — sandbox execution, hydration deadlock, test mock, copy casing * lint * improvement(workflows): adopt skipToken over enabled+as-string for type-safe conditional queries * improvement(workflows): remove dead complexity, fix mutation edge cases - Throw on state PUT failure in useCreateWorkflow instead of swallowing - Use Map for O(1) lookups in duplicate/export loops (3 hooks) - Broaden invalidation scope in update/delete mutations to lists() - Switch workflow-block to useWorkflowMap for direct ID lookup - Consolidate use-workflow-operations to single useWorkflowMap hook - Remove workspace transition guard (sync body, unreachable timeout) - Make switchToWorkspace synchronous (remove async/try-catch/finally) * fix(workflows): resolve cold-start deadlock on direct URL navigation loadWorkflowState used hydration.workspaceId (null on cold start) to look up the RQ cache, causing "Workflow not found" even when the workflow exists in the DB. Now falls back to getWorkspaceIdFromUrl() and skips the cache guard when the cache is empty (letting the API fetch proceed). Also removes the redundant isRegistryReady guard in workflow.tsx that blocked setActiveWorkflow when hydration.workspaceId was null. * fix(ui): prevent flash of empty state while workflows query is pending Dashboard and EmbeddedWorkflow checked workflow list length before the RQ query resolved, briefly showing "No workflows" or "Workflow not found" on initial load. Now gates on isPending first. * fix(workflows): address PR review — await description update, revert state PUT throw - api-info-modal: use mutateAsync for description update so errors are caught by the surrounding try/catch instead of silently swallowed - useCreateWorkflow: revert state PUT to log-only — the workflow is already created in the DB, throwing rolls back the optimistic entry and makes it appear the creation failed when it actually succeeded * move folders over to react query native, restructure passage of data * pass signal correctly * fix types * fix workspace id * address comment * soft deletion accuring --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1 parent d72577e commit df264d9

File tree

102 files changed

+2016
-1883
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+2016
-1883
lines changed

apps/sim/app/academy/components/sandbox-canvas-provider.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ import type {
1313
import { validateExercise } from '@/lib/academy/validation'
1414
import { cn } from '@/lib/core/utils/cn'
1515
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
16+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1617
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
1718
import { SandboxWorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1819
import Workflow from '@/app/workspace/[workspaceId]/w/[workflowId]/workflow'
1920
import { getBlock } from '@/blocks/registry'
21+
import { workflowKeys } from '@/hooks/queries/workflows'
2022
import { SandboxBlockConstraintsContext } from '@/hooks/use-sandbox-block-constraints'
2123
import { useExecutionStore } from '@/stores/execution/store'
2224
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
@@ -218,8 +220,13 @@ export function SandboxCanvasProvider({
218220

219221
useWorkflowStore.getState().replaceWorkflowState(workflowState)
220222
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks)
221-
useWorkflowRegistry.setState((state) => ({
222-
workflows: { ...state.workflows, [workflowId]: syntheticMetadata },
223+
224+
const qc = getQueryClient()
225+
const cacheKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
226+
const cached = qc.getQueryData<WorkflowMetadata[]>(cacheKey) ?? []
227+
qc.setQueryData(cacheKey, [...cached.filter((w) => w.id !== workflowId), syntheticMetadata])
228+
229+
useWorkflowRegistry.setState({
223230
activeWorkflowId: workflowId,
224231
hydration: {
225232
phase: 'ready',
@@ -228,7 +235,7 @@ export function SandboxCanvasProvider({
228235
requestId: null,
229236
error: null,
230237
},
231-
}))
238+
})
232239

233240
logger.info('Sandbox stores hydrated', { workflowId })
234241
setIsReady(true)
@@ -262,17 +269,21 @@ export function SandboxCanvasProvider({
262269
unsubWorkflow()
263270
unsubSubBlock()
264271
unsubExecution()
265-
useWorkflowRegistry.setState((state) => {
266-
const { [workflowId]: _removed, ...rest } = state.workflows
267-
return {
268-
workflows: rest,
269-
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
270-
hydration:
271-
state.hydration.workflowId === workflowId
272-
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
273-
: state.hydration,
274-
}
275-
})
272+
const cleanupQc = getQueryClient()
273+
const cleanupKey = workflowKeys.list(SANDBOX_WORKSPACE_ID, 'active')
274+
const cleanupCached = cleanupQc.getQueryData<WorkflowMetadata[]>(cleanupKey) ?? []
275+
cleanupQc.setQueryData(
276+
cleanupKey,
277+
cleanupCached.filter((w) => w.id !== workflowId)
278+
)
279+
280+
useWorkflowRegistry.setState((state) => ({
281+
activeWorkflowId: state.activeWorkflowId === workflowId ? null : state.activeWorkflowId,
282+
hydration:
283+
state.hydration.workflowId === workflowId
284+
? { phase: 'idle', workspaceId: null, workflowId: null, requestId: null, error: null }
285+
: state.hydration,
286+
}))
276287
useWorkflowStore.setState({ blocks: {}, edges: [], loops: {}, parallels: {} })
277288
useSubBlockStore.setState((state) => {
278289
const { [workflowId]: _removed, ...rest } = state.workflowValues

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function useAvailableResources(
4747
workspaceId: string,
4848
existingKeys: Set<string>
4949
): AvailableItemsByType[] {
50-
const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false })
50+
const { data: workflows = [] } = useWorkflows(workspaceId)
5151
const { data: tables = [] } = useTablesList(workspaceId)
5252
const { data: files = [] } = useWorkspaceFiles(workspaceId)
5353
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
3838
import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks'
3939
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
40+
import { useWorkflows } from '@/hooks/queries/workflows'
4041
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
4142
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4243
import { useExecutionStore } from '@/stores/execution/store'
@@ -375,15 +376,16 @@ interface EmbeddedWorkflowProps {
375376
}
376377

377378
function EmbeddedWorkflow({ workspaceId, workflowId }: EmbeddedWorkflowProps) {
378-
const workflowExists = useWorkflowRegistry((state) => Boolean(state.workflows[workflowId]))
379-
const isMetadataLoaded = useWorkflowRegistry(
380-
(state) => state.hydration.phase !== 'idle' && state.hydration.phase !== 'metadata-loading'
379+
const { data: workflowList, isPending: isWorkflowsPending } = useWorkflows(workspaceId)
380+
const workflowExists = useMemo(
381+
() => (workflowList ?? []).some((w) => w.id === workflowId),
382+
[workflowList, workflowId]
381383
)
382384
const hasLoadError = useWorkflowRegistry(
383385
(state) => state.hydration.phase === 'error' && state.hydration.workflowId === workflowId
384386
)
385387

386-
if (!isMetadataLoaded) return LOADING_SKELETON
388+
if (isWorkflowsPending) return LOADING_SKELETON
387389

388390
if (!workflowExists || hasLoadError) {
389391
return (

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx

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

3-
import type { ElementType, ReactNode } from 'react'
3+
import { type ElementType, type ReactNode, useMemo } from 'react'
44
import type { QueryClient } from '@tanstack/react-query'
5+
import { useParams } from 'next/navigation'
56
import {
67
Database,
78
File as FileIcon,
@@ -17,9 +18,9 @@ import type {
1718
} from '@/app/workspace/[workspaceId]/home/types'
1819
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
1920
import { tableKeys } from '@/hooks/queries/tables'
20-
import { workflowKeys } from '@/hooks/queries/workflows'
21+
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
22+
import { useWorkflows } from '@/hooks/queries/workflows'
2123
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
22-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2324

2425
interface DropdownItemRenderProps {
2526
item: { id: string; name: string; [key: string]: unknown }
@@ -34,7 +35,12 @@ export interface ResourceTypeConfig {
3435
}
3536

3637
function WorkflowTabSquare({ workflowId, className }: { workflowId: string; className?: string }) {
37-
const color = useWorkflowRegistry((state) => state.workflows[workflowId]?.color ?? '#888')
38+
const { workspaceId } = useParams<{ workspaceId: string }>()
39+
const { data: workflowList } = useWorkflows(workspaceId)
40+
const color = useMemo(() => {
41+
const wf = (workflowList ?? []).find((w) => w.id === workflowId)
42+
return wf?.color ?? '#888'
43+
}, [workflowList, workflowId])
3844
return (
3945
<div
4046
className={cn('flex-shrink-0 rounded-[3px] border-[2px]', className)}
@@ -157,8 +163,8 @@ const RESOURCE_INVALIDATORS: Record<
157163
qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) })
158164
qc.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
159165
},
160-
workflow: (qc, _wId) => {
161-
qc.invalidateQueries({ queryKey: workflowKeys.lists() })
166+
workflow: (qc, wId) => {
167+
void invalidateWorkflowLists(qc, wId)
162168
},
163169
knowledgebase: (qc, _wId, id) => {
164170
qc.invalidateQueries({ queryKey: knowledgeKeys.lists() })

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const PREVIEW_MODE_LABELS: Record<PreviewMode, string> = {
5353
* tabs always reflect the latest name even after a rename.
5454
*/
5555
function useResourceNameLookup(workspaceId: string): Map<string, string> {
56-
const { data: workflows = [] } = useWorkflows(workspaceId, { syncRegistry: false })
56+
const { data: workflows = [] } = useWorkflows(workspaceId)
5757
const { data: tables = [] } = useTablesList(workspaceId)
5858
const { data: files = [] } = useWorkspaceFiles(workspaceId)
5959
const { data: knowledgeBases } = useKnowledgeBasesQuery(workspaceId)

apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ import {
4545
computeMentionHighlightRanges,
4646
extractContextTokens,
4747
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
48+
import { useWorkflowMap } from '@/hooks/queries/workflows'
4849
import type { ChatContext } from '@/stores/panel'
49-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
5050

5151
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
5252

@@ -122,6 +122,7 @@ export function UserInput({
122122
onContextAdd,
123123
}: UserInputProps) {
124124
const { workspaceId } = useParams<{ workspaceId: string }>()
125+
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
125126
const { data: session } = useSession()
126127
const [value, setValue] = useState(defaultValue)
127128
const overlayRef = useRef<HTMLDivElement>(null)
@@ -617,7 +618,6 @@ export function UserInput({
617618

618619
const elements: React.ReactNode[] = []
619620
let lastIndex = 0
620-
621621
for (let i = 0; i < ranges.length; i++) {
622622
const range = ranges[i]
623623

@@ -639,7 +639,7 @@ export function UserInput({
639639
case 'workflow':
640640
case 'current_workflow': {
641641
const wfId = (matchingCtx as { workflowId: string }).workflowId
642-
const wfColor = useWorkflowRegistry.getState().workflows[wfId]?.color ?? '#888'
642+
const wfColor = workflowsById[wfId]?.color ?? '#888'
643643
mentionIconNode = (
644644
<div
645645
className='absolute inset-0 m-auto h-[12px] w-[12px] rounded-[3px] border-[2px]'
@@ -691,7 +691,7 @@ export function UserInput({
691691
}
692692

693693
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
694-
}, [value, contextManagement.selectedContexts])
694+
}, [value, contextManagement.selectedContexts, workflowsById])
695695

696696
return (
697697
<div

apps/sim/app/workspace/[workspaceId]/home/components/user-message-content/user-message-content.tsx

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

3+
import { useMemo } from 'react'
4+
import { useParams } from 'next/navigation'
35
import { Database, Table as TableIcon } from '@/components/emcn/icons'
46
import { getDocumentIcon } from '@/components/icons/document-icons'
57
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
6-
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
8+
import { useWorkflows } from '@/hooks/queries/workflows'
79

810
const USER_MESSAGE_CLASSES =
911
'whitespace-pre-wrap break-words [overflow-wrap:anywhere] font-[430] font-[family-name:var(--font-inter)] text-base text-[var(--text-primary)] leading-[23px] tracking-[0] antialiased'
@@ -44,12 +46,12 @@ function computeMentionRanges(text: string, contexts: ChatMessageContext[]): Men
4446
}
4547

4648
function MentionHighlight({ context }: { context: ChatMessageContext }) {
47-
const workflowColor = useWorkflowRegistry((state) => {
48-
if (context.kind === 'workflow' || context.kind === 'current_workflow') {
49-
return state.workflows[context.workflowId || '']?.color ?? null
50-
}
51-
return null
52-
})
49+
const { workspaceId } = useParams<{ workspaceId: string }>()
50+
const { data: workflowList } = useWorkflows(workspaceId)
51+
const workflowColor = useMemo(() => {
52+
if (context.kind !== 'workflow' && context.kind !== 'current_workflow') return null
53+
return (workflowList ?? []).find((w) => w.id === context.workflowId)?.color ?? null
54+
}, [workflowList, context.kind, context.workflowId])
5355

5456
let icon: React.ReactNode = null
5557
const iconClasses = 'h-[12px] w-[12px] flex-shrink-0 text-[var(--text-icon)]'

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,23 @@ import {
2121
import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types'
2222
import { isWorkflowToolName } from '@/lib/copilot/workflow-tools'
2323
import { getNextWorkflowColor } from '@/lib/workflows/colors'
24+
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
2425
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
26+
import type {
27+
ChatMessage,
28+
ChatMessageAttachment,
29+
ContentBlock,
30+
ContentBlockType,
31+
FileAttachmentForApi,
32+
GenericResourceData,
33+
GenericResourceEntry,
34+
MothershipResource,
35+
MothershipResourceType,
36+
QueuedMessage,
37+
SSEPayload,
38+
SSEPayloadData,
39+
ToolCallStatus,
40+
} from '@/app/workspace/[workspaceId]/home/types'
2541
import { deploymentKeys } from '@/hooks/queries/deployments'
2642
import {
2743
fetchChatHistory,
@@ -34,29 +50,17 @@ import {
3450
taskKeys,
3551
useChatHistory,
3652
} from '@/hooks/queries/tasks'
53+
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
54+
import { invalidateWorkflowSelectors } from '@/hooks/queries/utils/invalidate-workflow-lists'
3755
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
56+
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
3857
import { workflowKeys } from '@/hooks/queries/workflows'
3958
import { useExecutionStream } from '@/hooks/use-execution-stream'
4059
import { useExecutionStore } from '@/stores/execution/store'
41-
import { useFolderStore } from '@/stores/folders/store'
4260
import type { ChatContext } from '@/stores/panel'
4361
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
4462
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
45-
import type {
46-
ChatMessage,
47-
ChatMessageAttachment,
48-
ContentBlock,
49-
ContentBlockType,
50-
FileAttachmentForApi,
51-
GenericResourceData,
52-
GenericResourceEntry,
53-
MothershipResource,
54-
MothershipResourceType,
55-
QueuedMessage,
56-
SSEPayload,
57-
SSEPayloadData,
58-
ToolCallStatus,
59-
} from '../types'
63+
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
6064

6165
export interface UseChatReturn {
6266
messages: ChatMessage[]
@@ -301,31 +305,37 @@ function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined {
301305
return typeof payload.data === 'object' ? payload.data : undefined
302306
}
303307

304-
/** Adds a workflow to the registry with a top-insertion sort order if it doesn't already exist. */
308+
/** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */
305309
function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean {
306-
const registry = useWorkflowRegistry.getState()
307-
if (registry.workflows[resourceId]) return false
310+
const workflows = getWorkflows(workspaceId)
311+
if (workflows.some((w) => w.id === resourceId)) return false
308312
const sortOrder = getTopInsertionSortOrder(
309-
registry.workflows,
310-
useFolderStore.getState().folders,
313+
Object.fromEntries(workflows.map((w) => [w.id, w])),
314+
getFolderMap(workspaceId),
311315
workspaceId,
312316
null
313317
)
314-
useWorkflowRegistry.setState((state) => ({
315-
workflows: {
316-
...state.workflows,
317-
[resourceId]: {
318-
id: resourceId,
319-
name: title,
320-
lastModified: new Date(),
321-
createdAt: new Date(),
322-
color: getNextWorkflowColor(),
323-
workspaceId,
324-
folderId: null,
325-
sortOrder,
326-
},
327-
},
328-
}))
318+
const newMetadata: WorkflowMetadata = {
319+
id: resourceId,
320+
name: title,
321+
lastModified: new Date(),
322+
createdAt: new Date(),
323+
color: getNextWorkflowColor(),
324+
workspaceId,
325+
folderId: null,
326+
sortOrder,
327+
}
328+
const queryClient = getQueryClient()
329+
const key = workflowKeys.list(workspaceId, 'active')
330+
queryClient.setQueryData<WorkflowMetadata[]>(key, (current) => {
331+
const next = current ?? workflows
332+
if (next.some((workflow) => workflow.id === resourceId)) {
333+
return next
334+
}
335+
336+
return [...next, newMetadata]
337+
})
338+
void invalidateWorkflowSelectors(queryClient, workspaceId)
329339
return true
330340
}
331341

@@ -1253,7 +1263,7 @@ export function useChat(
12531263
? ((args as Record<string, unknown>).workflowId as string)
12541264
: useWorkflowRegistry.getState().activeWorkflowId
12551265
if (targetWorkflowId) {
1256-
const meta = useWorkflowRegistry.getState().workflows[targetWorkflowId]
1266+
const meta = getWorkflowById(workspaceId, targetWorkflowId)
12571267
const wasAdded = addResource({
12581268
type: 'workflow',
12591269
id: targetWorkflowId,

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
55
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
66
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
77
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
8+
import { WorkspaceScopeSync } from '@/app/workspace/[workspaceId]/providers/workspace-scope-sync'
89
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
910

1011
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
@@ -16,6 +17,7 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
1617
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
1718
<ImpersonationBanner />
1819
<WorkspacePermissionsProvider>
20+
<WorkspaceScopeSync />
1921
<div className='flex min-h-0 flex-1'>
2022
<div className='shrink-0' suppressHydrationWarning>
2123
<Sidebar />

0 commit comments

Comments
 (0)