Skip to content

Commit 3ce404d

Browse files
committed
perf(workflow): reduce canvas drag rendering and sync overhead
Isolate node data and search subscriptions to avoid unnecessary renders of the workflow root and floating controls during position updates. Reuse unchanged node references and broadcast incremental CRDT updates, with catch-up after session reauthorization. Add regression coverage for node data synchronization, toolbar behavior, and collaborative graph updates.
1 parent 2b5253f commit 3ce404d

20 files changed

Lines changed: 829 additions & 142 deletions
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { Node } from '../types'
2+
import { screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
5+
import { findWorkflowNodes } from '../goto-anything-search'
6+
import { useWorkflowSearch } from '../hooks/use-workflow-search'
7+
import AssignerNode from '../nodes/assigner/node'
8+
import DocumentExtractorNode from '../nodes/document-extractor/node'
9+
import ListOperatorNode from '../nodes/list-operator/node'
10+
import { BlockEnum, VarType } from '../types'
11+
import { createNode, createStartNode } from './fixtures'
12+
import { renderWorkflowFlowComponent } from './workflow-test-env'
13+
14+
const services = vi.hoisted(() => ({ tools: { data: [] }, selectNode: vi.fn() }))
15+
16+
vi.mock('../hooks/use-nodes-interactions', () => ({
17+
useNodesInteractions: () => ({ handleNodeSelect: services.selectNode }),
18+
}))
19+
20+
vi.mock('@/service/use-tools', () => ({
21+
useAllBuiltInTools: () => services.tools,
22+
useAllCustomTools: () => services.tools,
23+
useAllWorkflowTools: () => services.tools,
24+
useAllMCPTools: () => services.tools,
25+
}))
26+
27+
vi.mock('../nodes/_base/components/variable/variable-label', () => ({
28+
VariableLabelInNode: ({ nodeTitle, variables }: { nodeTitle?: string; variables: string[] }) => (
29+
<span>{`${nodeTitle ?? 'Missing source'}:${variables.join('.')}`}</span>
30+
),
31+
}))
32+
33+
const sourceNode = createStartNode({ id: 'source', data: { title: 'Input files' } })
34+
35+
function GraphControls() {
36+
const store = useStoreApi()
37+
const position = useReactFlowStore((state) => state.nodeInternals.get('source')?.position)
38+
const updateNodes = (update: (nodes: Node[]) => Node[]) => {
39+
const { getNodes, setNodes } = store.getState()
40+
setNodes(update(getNodes() as Node[]))
41+
}
42+
43+
return (
44+
<>
45+
<output aria-label="Source position">
46+
{position ? `${position.x},${position.y}` : 'removed'}
47+
</output>
48+
<button
49+
type="button"
50+
onClick={() =>
51+
updateNodes((nodes) =>
52+
nodes.map((node) =>
53+
node.id === 'source' ? { ...node, position: { x: 240, y: 360 } } : node,
54+
),
55+
)
56+
}
57+
>
58+
Move source
59+
</button>
60+
<button
61+
type="button"
62+
onClick={() =>
63+
updateNodes((nodes) =>
64+
nodes.map((node) =>
65+
node.id === 'source'
66+
? { ...node, data: { ...node.data, title: 'Renamed input' } }
67+
: node,
68+
),
69+
)
70+
}
71+
>
72+
Rename source
73+
</button>
74+
<button
75+
type="button"
76+
onClick={() => updateNodes((nodes) => nodes.filter((node) => node.id !== 'source'))}
77+
>
78+
Remove source
79+
</button>
80+
<button
81+
type="button"
82+
onClick={() =>
83+
updateNodes((nodes) => [
84+
...nodes,
85+
{ ...sourceNode, data: { ...sourceNode.data, title: 'Restored input' } },
86+
])
87+
}
88+
>
89+
Restore source
90+
</button>
91+
</>
92+
)
93+
}
94+
95+
function SearchRegistration() {
96+
useWorkflowSearch()
97+
return null
98+
}
99+
100+
function NodePreviews() {
101+
return (
102+
<>
103+
<DocumentExtractorNode
104+
id="extractor"
105+
data={{
106+
type: BlockEnum.DocExtractor,
107+
title: 'Extract',
108+
desc: '',
109+
variable_selector: ['source', 'files'],
110+
is_array_file: false,
111+
}}
112+
/>
113+
<ListOperatorNode
114+
id="list"
115+
data={{
116+
type: BlockEnum.ListFilter,
117+
title: 'List',
118+
desc: '',
119+
variable: ['source', 'items'],
120+
var_type: VarType.arrayString,
121+
item_var_type: VarType.string,
122+
filter_by: { enabled: false, conditions: [] },
123+
extract_by: { enabled: false },
124+
order_by: { enabled: false, key: [], value: 'asc' },
125+
limit: { enabled: false },
126+
}}
127+
/>
128+
<AssignerNode
129+
id="assigner"
130+
data={{
131+
type: BlockEnum.Assigner,
132+
title: 'Assign',
133+
desc: '',
134+
version: '2',
135+
items: [
136+
{
137+
variable_selector: ['source', 'count'],
138+
input_type: 'variable',
139+
operation: 'over-write',
140+
value: '',
141+
},
142+
],
143+
}}
144+
/>
145+
</>
146+
)
147+
}
148+
149+
describe('workflow node data synchronization', () => {
150+
it('keeps positions live and updates node labels and search after content changes, deletion and restore', async () => {
151+
const user = userEvent.setup()
152+
renderWorkflowFlowComponent(
153+
<>
154+
<GraphControls />
155+
<SearchRegistration />
156+
<NodePreviews />
157+
</>,
158+
{ nodes: [sourceNode, createNode({ id: 'unrelated' })] },
159+
)
160+
161+
expect(screen.getByText('Input files:source.files')).toBeInTheDocument()
162+
expect(screen.getByText('Input files:source.items')).toBeInTheDocument()
163+
expect(screen.getByText('Input files:source.count')).toBeInTheDocument()
164+
expect(findWorkflowNodes('Input files')[0]?.id).toBe('source')
165+
166+
await user.click(screen.getByRole('button', { name: 'Move source' }))
167+
expect(screen.getByLabelText('Source position')).toHaveTextContent('240,360')
168+
expect(screen.getByText('Input files:source.files')).toBeInTheDocument()
169+
170+
await user.click(screen.getByRole('button', { name: 'Rename source' }))
171+
expect(screen.getByText('Renamed input:source.files')).toBeInTheDocument()
172+
expect(screen.getByText('Renamed input:source.items')).toBeInTheDocument()
173+
expect(screen.getByText('Renamed input:source.count')).toBeInTheDocument()
174+
expect(findWorkflowNodes('Input files')).toEqual([])
175+
expect(findWorkflowNodes('Renamed input')[0]?.id).toBe('source')
176+
177+
await user.click(screen.getByRole('button', { name: 'Remove source' }))
178+
expect(screen.getByText('Missing source:source.files')).toBeInTheDocument()
179+
expect(findWorkflowNodes('Renamed input')).toEqual([])
180+
181+
await user.click(screen.getByRole('button', { name: 'Restore source' }))
182+
expect(screen.getByText('Restored input:source.files')).toBeInTheDocument()
183+
expect(screen.getByText('Restored input:source.items')).toBeInTheDocument()
184+
expect(screen.getByText('Restored input:source.count')).toBeInTheDocument()
185+
expect(findWorkflowNodes('Restored input')[0]?.id).toBe('source')
186+
})
187+
188+
it('continues resolving system variables through the start node', async () => {
189+
const user = userEvent.setup()
190+
renderWorkflowFlowComponent(
191+
<>
192+
<GraphControls />
193+
<DocumentExtractorNode
194+
id="extractor"
195+
data={{
196+
type: BlockEnum.DocExtractor,
197+
title: 'Extract',
198+
desc: '',
199+
variable_selector: ['sys', 'files'],
200+
is_array_file: false,
201+
}}
202+
/>
203+
</>,
204+
{ nodes: [sourceNode] },
205+
)
206+
207+
expect(screen.getByText('Input files:sys.files')).toBeInTheDocument()
208+
await user.click(screen.getByRole('button', { name: 'Rename source' }))
209+
expect(screen.getByText('Renamed input:sys.files')).toBeInTheDocument()
210+
})
211+
})

‎web/app/components/workflow/__tests__/workflow-test-env.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { WorkflowContext } from '../context'
8484
import { EditorResourceScope } from '../editor-resource/provider'
8585
import { HooksStoreContext } from '../hooks-store/provider'
8686
import { createHooksStore } from '../hooks-store/store'
87+
import WorkflowNodesDataSync from '../nodes-data-sync'
8788
import { createWorkflowStore } from '../store/workflow'
8889
import { WorkflowRunningStatus } from '../types'
8990

@@ -332,6 +333,7 @@ function createWorkflowFlowWrapper(
332333
ReactFlowProvider,
333334
null,
334335
React.createElement(ReactFlow, { fitView: true, ...reactFlowProps, nodes, edges }),
336+
React.createElement(WorkflowNodesDataSync),
335337
children,
336338
),
337339
),

0 commit comments

Comments
 (0)