perf(workflow): lazy load node configuration panels (#42970)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-09-28 09:07:08 +00:00
1 parent 7020120197
commit 5f6bb5e6d3
13 files changed
+337 -105

No files matched your search

+1 -1
View File
@@ -3179,7 +3179,7 @@
},
"web/app/components/workflow/nodes/index.tsx": {
"eslint-react/static-components": {
"count": 4
"count": 2
}
},
"web/app/components/workflow/nodes/iteration/node.tsx": {
@@ -3,13 +3,17 @@ import type { Node as WorkflowNode } from '../../types'
import { render, screen } from '@testing-library/react'
import { CUSTOM_NODE } from '../../constants'
import { BlockEnum } from '../../types'
import CustomNode, { Panel } from '../index'
import CustomNode from '../index'
import Panel from '../panel'
vi.mock('../components', () => ({
NodeComponentMap: {
[BlockEnum.Start]: () => <div>start-node-component</div>,
[BlockEnum.StartPlaceholder]: () => <div>start-placeholder-node-component</div>,
},
}))
vi.mock('../panel-components', () => ({
PanelComponentMap: {
[BlockEnum.Start]: () => <div>start-panel-component</div>,
[BlockEnum.StartPlaceholder]: () => <div>start-placeholder-panel-component</div>,
@@ -0,0 +1,121 @@
import type { ComponentType } from 'react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { lazyPanel } from '../lazy-panel'
type EditorProps = { title: string }
function Editor({ title }: EditorProps) {
const [draft, setDraft] = useState('')
return (
<label>
{title}
<input value={draft} onChange={(event) => setDraft(event.target.value)} />
</label>
)
}
function deferredPanel() {
let resolve!: (value: { default: ComponentType<EditorProps> }) => void
const promise = new Promise<{ default: ComponentType<EditorProps> }>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('lazy node panels', () => {
it('loads only when selected and preserves an unsaved edit when node props update', async () => {
const user = userEvent.setup()
const pending = deferredPanel()
const load = vi.fn(() => pending.promise)
const Panel = lazyPanel(load)
const { rerender } = render(<div />)
expect(load).not.toHaveBeenCalled()
rerender(<Panel title="Prompt" />)
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
await act(async () => pending.resolve({ default: Editor }))
await user.type(screen.getByRole('textbox', { name: 'Prompt' }), 'Unsaved prompt')
rerender(<Panel title="Updated prompt" />)
expect(screen.getByRole('textbox', { name: 'Updated prompt' })).toHaveValue('Unsaved prompt')
expect(load).toHaveBeenCalledTimes(1)
})
it('does not replace the selected panel when a previous selection finishes loading', async () => {
const first = deferredPanel()
const second = deferredPanel()
const FirstPanel = lazyPanel(() => first.promise)
const SecondPanel = lazyPanel(() => second.promise)
const { rerender } = render(<FirstPanel title="First node" />)
rerender(<SecondPanel title="Second node" />)
await act(async () => second.resolve({ default: Editor }))
expect(screen.getByRole('textbox', { name: 'Second node' })).toBeInTheDocument()
await act(async () => first.resolve({ default: Editor }))
expect(screen.queryByRole('textbox', { name: 'First node' })).not.toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Second node' })).toBeInTheDocument()
})
it('keeps the surrounding editor available and retries a failed panel import', async () => {
const user = userEvent.setup()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const load = vi
.fn<() => Promise<{ default: ComponentType<EditorProps> }>>()
.mockRejectedValueOnce(new Error('Chunk unavailable'))
.mockResolvedValueOnce({ default: Editor })
const Panel = lazyPanel(load)
try {
render(
<>
<button type="button">Close panel</button>
<Panel title="Recovered panel" />
</>,
)
await user.click(await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }))
expect(await screen.findByRole('textbox', { name: 'Recovered panel' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close panel' })).toBeEnabled()
expect(load).toHaveBeenCalledTimes(2)
} finally {
consoleError.mockRestore()
}
})
it.each(['reopen', 'switch'] as const)(
'retains a successful import retry across a panel %s',
async (transition) => {
const user = userEvent.setup()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const load = vi
.fn<() => Promise<{ default: ComponentType<EditorProps> }>>()
.mockRejectedValueOnce(new Error('Chunk unavailable'))
.mockResolvedValue({ default: Editor })
const Panel = lazyPanel(load)
try {
const { rerender } = render(<Panel key="first-node" title="First node" />)
await user.click(
await screen.findByRole('button', { name: 'common.errorBoundary.tryAgain' }),
)
expect(await screen.findByRole('textbox', { name: 'First node' })).toBeInTheDocument()
if (transition === 'reopen') {
rerender(<div />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
}
const nextTitle = transition === 'reopen' ? 'First node' : 'Second node'
const nextKey = transition === 'reopen' ? 'first-node' : 'second-node'
rerender(<Panel key={nextKey} title={nextTitle} />)
const input = await screen.findByRole('textbox', { name: nextTitle })
await user.type(input, 'Recovered edit')
expect(input).toHaveValue('Recovered edit')
expect(
screen.queryByRole('button', { name: 'common.errorBoundary.tryAgain' }),
).not.toBeInTheDocument()
expect(load).toHaveBeenCalledTimes(2)
} finally {
consoleError.mockRestore()
}
},
)
})
@@ -0,0 +1,55 @@
import type { ReactElement } from 'react'
import type { Node } from '../../types'
import { render, screen } from '@testing-library/react'
import { cloneElement } from 'react'
import { CUSTOM_NODE } from '../../constants'
import { BlockEnum } from '../../types'
import Panel from '../panel'
type InjectedProps = { id: string; data: Node['data'] }
const loads = vi.hoisted(() => ({ legacy: vi.fn(), v2: vi.fn() }))
vi.mock('../agent/panel', () => {
loads.legacy()
return {
default: ({ id, data }: InjectedProps) => (
<div>
Legacy: {id} {data.title}
</div>
),
}
})
vi.mock('../agent-v2/panel', () => {
loads.v2()
return {
AgentV2Panel: ({ id, data }: InjectedProps) => (
<div>
V2: {id} {data.title}
</div>
),
}
})
// The shell owns running and resizing. Preserve its node-prop injection contract
// while exercising the real registry, lazy loaders, and Agent discriminator.
vi.mock('../_base/components/workflow-panel', () => ({
default: ({ children, ...props }: InjectedProps & { children: ReactElement<InjectedProps> }) =>
cloneElement(children, props),
}))
it('loads the correct Agent variant for legacy and explicit V2 node types', async () => {
const data: Node['data'] = { title: 'Agent', desc: '', type: BlockEnum.Agent }
expect(loads.legacy).not.toHaveBeenCalled()
expect(loads.v2).not.toHaveBeenCalled()
const { rerender } = render(<Panel id="agent-1" type={CUSTOM_NODE} data={data} />)
expect(await screen.findByText('Legacy: agent-1 Agent')).toBeInTheDocument()
expect(loads.v2).not.toHaveBeenCalled()
const v2Data = { ...data, agent_node_kind: 'dify_agent', version: '2' }
rerender(<Panel id="agent-1" type={CUSTOM_NODE} data={v2Data} />)
expect(await screen.findByText('V2: agent-1 Agent')).toBeInTheDocument()
expect(screen.queryByText('Legacy: agent-1 Agent')).not.toBeInTheDocument()
rerender(<Panel id="agent-2" type={CUSTOM_NODE} data={{ ...v2Data, type: BlockEnum.AgentV2 }} />)
expect(await screen.findByText('V2: agent-2 Agent')).toBeInTheDocument()
expect(loads.legacy).toHaveBeenCalledTimes(1)
expect(loads.v2).toHaveBeenCalledTimes(1)
})
@@ -2,65 +2,35 @@ import type { ComponentProps, ComponentType } from 'react'
import { createElement } from 'react'
import { BlockEnum } from '../types'
import { AgentV2Node } from './agent-v2/node'
import { AgentV2Panel } from './agent-v2/panel'
import { isAgentV2NodeData } from './agent-v2/types'
import AgentNode from './agent/node'
import AgentPanel from './agent/panel'
import AnswerNode from './answer/node'
import AnswerPanel from './answer/panel'
import AssignerNode from './assigner/node'
import AssignerPanel from './assigner/panel'
import CodeNode from './code/node'
import CodePanel from './code/panel'
import DataSourceNode from './data-source/node'
import DataSourcePanel from './data-source/panel'
import DocExtractorNode from './document-extractor/node'
import DocExtractorPanel from './document-extractor/panel'
import EndNode from './end/node'
import EndPanel from './end/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
import HumanInputNode from './human-input/node'
import HumanInputPanel from './human-input/panel'
import IfElseNode from './if-else/node'
import IfElsePanel from './if-else/panel'
import IterationNode from './iteration/node'
import IterationPanel from './iteration/panel'
import KnowledgeBaseNode from './knowledge-base/node'
import KnowledgeBasePanel from './knowledge-base/panel'
import KnowledgeRetrievalNode from './knowledge-retrieval/node'
import KnowledgeRetrievalPanel from './knowledge-retrieval/panel'
import ListFilterNode from './list-operator/node'
import ListFilterPanel from './list-operator/panel'
import LLMNode from './llm/node'
import LLMPanel from './llm/panel'
import LoopNode from './loop/node'
import LoopPanel from './loop/panel'
import ParameterExtractorNode from './parameter-extractor/node'
import ParameterExtractorPanel from './parameter-extractor/panel'
import QuestionClassifierNode from './question-classifier/node'
import QuestionClassifierPanel from './question-classifier/panel'
import StartPlaceholderNode from './start-placeholder/node'
import StartPlaceholderPanel from './start-placeholder/panel'
import StartNode from './start/node'
import StartPanel from './start/panel'
import TemplateTransformNode from './template-transform/node'
import TemplateTransformPanel from './template-transform/panel'
import ToolNode from './tool/node'
import ToolPanel from './tool/panel'
import TriggerPluginNode from './trigger-plugin/node'
import TriggerPluginPanel from './trigger-plugin/panel'
import TriggerScheduleNode from './trigger-schedule/node'
import TriggerSchedulePanel from './trigger-schedule/panel'
import TriggerWebhookNode from './trigger-webhook/node'
import TriggerWebhookPanel from './trigger-webhook/panel'
import VariableAssignerNode from './variable-assigner/node'
import VariableAssignerPanel from './variable-assigner/panel'
type WorkflowAgentNodeProps = ComponentProps<typeof AgentNode> | ComponentProps<typeof AgentV2Node>
type WorkflowAgentPanelProps =
| ComponentProps<typeof AgentPanel>
| ComponentProps<typeof AgentV2Panel>
type WorkflowComponentMap = Record<string, ComponentType<Record<string, never>>>
function WorkflowAgentNode(props: WorkflowAgentNodeProps) {
@@ -70,13 +40,6 @@ function WorkflowAgentNode(props: WorkflowAgentNodeProps) {
return createElement(AgentNode, props as ComponentProps<typeof AgentNode>)
}
function WorkflowAgentPanel(props: WorkflowAgentPanelProps) {
if (isAgentV2NodeData(props.data))
return createElement(AgentV2Panel, props as ComponentProps<typeof AgentV2Panel>)
return createElement(AgentPanel, props as ComponentProps<typeof AgentPanel>)
}
export const NodeComponentMap = {
[BlockEnum.Start]: StartNode,
[BlockEnum.StartPlaceholder]: StartPlaceholderNode,
@@ -107,34 +70,3 @@ export const NodeComponentMap = {
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
} as unknown as WorkflowComponentMap
export const PanelComponentMap = {
[BlockEnum.Start]: StartPanel,
[BlockEnum.StartPlaceholder]: StartPlaceholderPanel,
[BlockEnum.End]: EndPanel,
[BlockEnum.Answer]: AnswerPanel,
[BlockEnum.LLM]: LLMPanel,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalPanel,
[BlockEnum.QuestionClassifier]: QuestionClassifierPanel,
[BlockEnum.IfElse]: IfElsePanel,
[BlockEnum.Code]: CodePanel,
[BlockEnum.TemplateTransform]: TemplateTransformPanel,
[BlockEnum.HttpRequest]: HttpPanel,
[BlockEnum.Tool]: ToolPanel,
[BlockEnum.VariableAssigner]: VariableAssignerPanel,
[BlockEnum.VariableAggregator]: VariableAssignerPanel,
[BlockEnum.Assigner]: AssignerPanel,
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
[BlockEnum.Iteration]: IterationPanel,
[BlockEnum.Loop]: LoopPanel,
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: WorkflowAgentPanel,
[BlockEnum.AgentV2]: AgentV2Panel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
[BlockEnum.HumanInput]: HumanInputPanel,
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
} as unknown as WorkflowComponentMap
+1 -30
View File
@@ -1,10 +1,7 @@
import type { NodeProps } from 'reactflow'
import type { Node } from '../types'
import { memo, useMemo } from 'react'
import { CUSTOM_NODE } from '../constants'
import BasePanel from './_base/components/workflow-panel'
import BaseNode from './_base/node'
import { NodeComponentMap, PanelComponentMap } from './components'
import { NodeComponentMap } from './components'
const CustomNode = (props: NodeProps) => {
const nodeData = props.data
@@ -20,30 +17,4 @@ const CustomNode = (props: NodeProps) => {
}
CustomNode.displayName = 'CustomNode'
type PanelProps = {
type: Node['type']
id: Node['id']
data: Node['data']
}
export const Panel = memo((props: PanelProps) => {
const nodeClass = props.type
const nodeData = props.data
const PanelComponent = useMemo(() => {
if (nodeClass === CUSTOM_NODE) return PanelComponentMap[nodeData.type]
return () => null
}, [nodeClass, nodeData.type])!
if (nodeClass === CUSTOM_NODE) {
return (
<BasePanel key={`${props.id}-${nodeData.type}`} id={props.id} data={props.data}>
<PanelComponent />
</BasePanel>
)
}
return null
})
Panel.displayName = 'Panel'
export default memo(CustomNode)
@@ -0,0 +1,28 @@
import type { ComponentType } from 'react'
import { createElement, lazy, Suspense, useState } from 'react'
import ErrorBoundary from '@/app/components/base/error-boundary'
import PanelLoading from './panel-loading'
// Keep the component identity stable while BasePanel injects updated node data
// and run parameters. Share explicit retries with future mounts so reopening a
// recovered panel does not reuse the original lazy component's cached rejection.
export function lazyPanel<Props extends object>(
load: () => Promise<{ default: ComponentType<Props> }>,
) {
let CurrentPanel = lazy(load)
return function LazyPanel(props: Props) {
const [Panel, setPanel] = useState(() => CurrentPanel)
const retry = () => {
CurrentPanel = lazy(load)
setPanel(() => CurrentPanel)
}
return (
<ErrorBoundary onReset={retry}>
<Suspense fallback={<PanelLoading />}>{createElement(Panel, props)}</Suspense>
</ErrorBoundary>
)
}
}
@@ -0,0 +1,76 @@
import type { ComponentProps, ComponentType } from 'react'
import { createElement } from 'react'
import { BlockEnum } from '../types'
import { isAgentV2NodeData } from './agent-v2/types'
import { lazyPanel } from './lazy-panel'
const AgentV2Panel = lazyPanel(async () => {
const { AgentV2Panel } = await import('./agent-v2/panel')
return { default: AgentV2Panel }
})
const AgentPanel = lazyPanel(() => import('./agent/panel'))
const AnswerPanel = lazyPanel(() => import('./answer/panel'))
const AssignerPanel = lazyPanel(() => import('./assigner/panel'))
const CodePanel = lazyPanel(() => import('./code/panel'))
const DataSourcePanel = lazyPanel(() => import('./data-source/panel'))
const DocExtractorPanel = lazyPanel(() => import('./document-extractor/panel'))
const EndPanel = lazyPanel(() => import('./end/panel'))
const HttpPanel = lazyPanel(() => import('./http/panel'))
const HumanInputPanel = lazyPanel(() => import('./human-input/panel'))
const IfElsePanel = lazyPanel(() => import('./if-else/panel'))
const IterationPanel = lazyPanel(() => import('./iteration/panel'))
const KnowledgeBasePanel = lazyPanel(() => import('./knowledge-base/panel'))
const KnowledgeRetrievalPanel = lazyPanel(() => import('./knowledge-retrieval/panel'))
const ListFilterPanel = lazyPanel(() => import('./list-operator/panel'))
const LLMPanel = lazyPanel(() => import('./llm/panel'))
const LoopPanel = lazyPanel(() => import('./loop/panel'))
const ParameterExtractorPanel = lazyPanel(() => import('./parameter-extractor/panel'))
const QuestionClassifierPanel = lazyPanel(() => import('./question-classifier/panel'))
const StartPlaceholderPanel = lazyPanel(() => import('./start-placeholder/panel'))
const StartPanel = lazyPanel(() => import('./start/panel'))
const TemplateTransformPanel = lazyPanel(() => import('./template-transform/panel'))
const ToolPanel = lazyPanel(() => import('./tool/panel'))
const TriggerPluginPanel = lazyPanel(() => import('./trigger-plugin/panel'))
const TriggerSchedulePanel = lazyPanel(() => import('./trigger-schedule/panel'))
const TriggerWebhookPanel = lazyPanel(() => import('./trigger-webhook/panel'))
const VariableAssignerPanel = lazyPanel(() => import('./variable-assigner/panel'))
function WorkflowAgentPanel(
props: ComponentProps<typeof AgentPanel> | ComponentProps<typeof AgentV2Panel>,
) {
if (isAgentV2NodeData(props.data))
return createElement(AgentV2Panel, props as ComponentProps<typeof AgentV2Panel>)
return createElement(AgentPanel, props as ComponentProps<typeof AgentPanel>)
}
export const PanelComponentMap = {
[BlockEnum.Start]: StartPanel,
[BlockEnum.StartPlaceholder]: StartPlaceholderPanel,
[BlockEnum.End]: EndPanel,
[BlockEnum.Answer]: AnswerPanel,
[BlockEnum.LLM]: LLMPanel,
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrievalPanel,
[BlockEnum.QuestionClassifier]: QuestionClassifierPanel,
[BlockEnum.IfElse]: IfElsePanel,
[BlockEnum.Code]: CodePanel,
[BlockEnum.TemplateTransform]: TemplateTransformPanel,
[BlockEnum.HttpRequest]: HttpPanel,
[BlockEnum.Tool]: ToolPanel,
[BlockEnum.VariableAssigner]: VariableAssignerPanel,
[BlockEnum.VariableAggregator]: VariableAssignerPanel,
[BlockEnum.Assigner]: AssignerPanel,
[BlockEnum.ParameterExtractor]: ParameterExtractorPanel,
[BlockEnum.Iteration]: IterationPanel,
[BlockEnum.Loop]: LoopPanel,
[BlockEnum.DocExtractor]: DocExtractorPanel,
[BlockEnum.ListFilter]: ListFilterPanel,
[BlockEnum.Agent]: WorkflowAgentPanel,
[BlockEnum.AgentV2]: AgentV2Panel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
[BlockEnum.HumanInput]: HumanInputPanel,
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
} as unknown as Record<string, ComponentType<Record<string, never>>>
@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
function PanelLoading() {
const { t } = useTranslation(['common'])
return (
<SkeletonContainer role="status" aria-label={t(($) => $.loading)} className="p-4">
<SkeletonRectangle className="h-4 w-24" />
<SkeletonRectangle className="h-20 w-full" />
<SkeletonRectangle className="h-4 w-24" />
<SkeletonRectangle className="h-20 w-full" />
</SkeletonContainer>
)
}
export default PanelLoading
@@ -0,0 +1,28 @@
import type { Node } from '../types'
import { memo } from 'react'
import { CUSTOM_NODE } from '../constants'
import BasePanel from './_base/components/workflow-panel'
import { PanelComponentMap } from './panel-components'
type PanelProps = {
type: Node['type']
id: Node['id']
data: Node['data']
}
const Panel = memo((props: PanelProps) => {
const nodeClass = props.type
const nodeData = props.data
if (nodeClass !== CUSTOM_NODE) return null
const PanelComponent = PanelComponentMap[nodeData.type]!
return (
<BasePanel key={`${props.id}-${nodeData.type}`} id={props.id} data={props.data}>
<PanelComponent />
</BasePanel>
)
})
Panel.displayName = 'Panel'
export default Panel
@@ -87,8 +87,8 @@ vi.mock('../../store', () => ({
useStore: <T,>(selector: (state: MockPanelStoreState) => T) => selector(mockPanelStoreState),
}))
vi.mock('../../nodes', () => ({
Panel: ({ id, data }: { id: string; data: MockNodeData }) => (
vi.mock('../../nodes/panel', () => ({
default: ({ id, data }: { id: string; data: MockNodeData }) => (
<div data-testid="node-panel">{`${id}:${data.title || 'untitled'}`}</div>
),
}))
@@ -35,8 +35,8 @@ vi.mock('@/app/components/workflow/panel/inputs-panel', () => ({ default: () =>
vi.mock('@/app/components/workflow/panel/env-panel', () => ({ default: () => null }))
// Node details do not own the preview width limit. Node-panel compression is
// covered separately at the real BasePanel boundary.
vi.mock('@/app/components/workflow/nodes', () => ({
Panel: () => <div>Selected node details</div>,
vi.mock('@/app/components/workflow/nodes/panel', () => ({
default: () => <div>Selected node details</div>,
}))
function SelectedNodePanel({ children }: { children: ReactNode }) {
+1 -1
View File
@@ -5,7 +5,7 @@ import dynamic from 'next/dynamic'
import { memo, useEffect, useRef } from 'react'
import { useStore as useReactflow } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { Panel as NodePanel } from '../nodes'
import NodePanel from '../nodes/panel'
import { useStore } from '../store'
import EnvPanel from './env-panel'
import { getPreviewPanelMaxWidth } from './panel-width'