mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
fix(workflow): eliminate infinite loop in plugin install state management
Replace useEffect-based state sync (_pluginInstallLocked/_dimmed) with render-time derived computation in BaseNode, breaking the cycle of effect → node data update → re-render → effect. Extract plugin missing check into a pure utility function for checklist reuse.
This commit is contained in:
@ -33,6 +33,7 @@ import { useStrategyProviders } from '@/service/use-strategy'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
@ -56,6 +57,7 @@ import {
|
||||
getToolCheckParams,
|
||||
getValidTreeNodes,
|
||||
} from '../utils'
|
||||
import { isNodePluginMissing } from '../utils/plugin-install-check'
|
||||
import { getTriggerCheckParams } from '../utils/trigger'
|
||||
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
|
||||
|
||||
@ -77,13 +79,6 @@ const START_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
// Node types that depend on plugins
|
||||
const PLUGIN_DEPENDENT_TYPES: BlockEnum[] = [
|
||||
BlockEnum.Tool,
|
||||
BlockEnum.DataSource,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
@ -91,6 +86,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
@ -166,7 +162,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
if (node.type === CUSTOM_NODE) {
|
||||
const checkData = getCheckData(node.data)
|
||||
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
|
||||
const isPluginMissing = PLUGIN_DEPENDENT_TYPES.includes(node.data.type as BlockEnum) && node.data._pluginInstallLocked
|
||||
const isPluginMissing = isNodePluginMissing(node.data, { builtInTools: buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, dataSourceList })
|
||||
|
||||
// Check if plugin is installed for plugin-dependent nodes first
|
||||
let errorMessage: string | undefined
|
||||
@ -250,7 +246,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
})
|
||||
|
||||
return list
|
||||
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
|
||||
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, getToolIcon, strategyProviders, triggerPlugins, getCheckData, t, map, shouldCheckStartNode])
|
||||
|
||||
return needWarningNodes
|
||||
}
|
||||
|
||||
@ -591,8 +591,6 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
if (node.data.type === BlockEnum.DataSourceEmpty)
|
||||
return
|
||||
if (node.data._pluginInstallLocked)
|
||||
return
|
||||
handleNodeSelect(node.id)
|
||||
},
|
||||
[handleNodeSelect, workflowStore],
|
||||
|
||||
@ -22,10 +22,13 @@ import { NodeRunningStatus } from '../../../types'
|
||||
import { canRunBySingle } from '../../../utils'
|
||||
import PanelOperator from './panel-operator'
|
||||
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'>
|
||||
type NodeControlProps = Pick<Node, 'id' | 'data'> & {
|
||||
pluginInstallLocked?: boolean
|
||||
}
|
||||
const NodeControl: FC<NodeControlProps> = ({
|
||||
id,
|
||||
data,
|
||||
pluginInstallLocked,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -47,7 +50,7 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
<div
|
||||
className={`
|
||||
absolute -top-7 right-0 hidden h-7 pb-1
|
||||
${!data._pluginInstallLocked && 'group-hover:flex'}
|
||||
${!pluginInstallLocked && 'group-hover:flex'}
|
||||
${data.selected && '!flex'}
|
||||
${open && '!flex'}
|
||||
`}
|
||||
|
||||
@ -25,6 +25,7 @@ import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
|
||||
import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration'
|
||||
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
|
||||
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
|
||||
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
|
||||
@ -81,6 +82,9 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
const appId = useStore(s => s.appId)
|
||||
const { nodePanelPresence } = useCollaboration(appId as string)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const { shouldDim: pluginShouldDim, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
|
||||
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
|
||||
const pluginDimmed = pluginShouldDim
|
||||
|
||||
const currentUserPresence = useMemo(() => {
|
||||
const userId = userProfile?.id || ''
|
||||
@ -226,7 +230,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
'relative flex rounded-2xl border',
|
||||
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
|
||||
data._waitingRun && 'opacity-70',
|
||||
data._pluginInstallLocked && 'cursor-not-allowed',
|
||||
pluginInstallLocked && 'cursor-not-allowed',
|
||||
)}
|
||||
ref={nodeRef}
|
||||
style={{
|
||||
@ -234,14 +238,15 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
|
||||
}}
|
||||
>
|
||||
{(data._dimmed || data._pluginInstallLocked) && (
|
||||
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-2xl transition-opacity',
|
||||
data._pluginInstallLocked
|
||||
pluginInstallLocked
|
||||
? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]'
|
||||
: 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50',
|
||||
)}
|
||||
onClick={pluginInstallLocked ? e => e.stopPropagation() : undefined}
|
||||
data-testid="workflow-node-install-overlay"
|
||||
/>
|
||||
)}
|
||||
@ -318,6 +323,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
<NodeControl
|
||||
id={id}
|
||||
data={data}
|
||||
pluginInstallLocked={pluginInstallLocked}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import { memo, useEffect } from 'react'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { memo } from 'react'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
|
||||
const Node: FC<NodeProps<DataSourceNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const {
|
||||
@ -16,22 +14,7 @@ const Node: FC<NodeProps<DataSourceNodeType>> = ({
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
|
||||
|
||||
@ -4,14 +4,13 @@ import type { StrategyDetail, StrategyPluginDetail } from '@/app/components/plug
|
||||
import type { AgentNodeType } from '@/app/components/workflow/nodes/agent/types'
|
||||
import type { CommonNodeType, NodeProps, Node as WorkflowNode } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodes } from 'reactflow'
|
||||
import AlertTriangle from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertTriangle'
|
||||
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useNodesMetaData } from '@/app/components/workflow/hooks'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@ -48,21 +47,6 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const nodesById = useMemo(() => {
|
||||
return nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node
|
||||
|
||||
@ -2,10 +2,9 @@ import type { FC } from 'react'
|
||||
import type { PluginTriggerNodeType } from './types'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
|
||||
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
|
||||
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
|
||||
import useConfig from './use-config'
|
||||
@ -54,21 +53,7 @@ const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
} = useNodePluginInstallation(data)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
|
||||
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
|
||||
return
|
||||
handleNodeDataUpdate({
|
||||
id,
|
||||
data: {
|
||||
_pluginInstallLocked: shouldLock,
|
||||
_dimmed: shouldDim,
|
||||
},
|
||||
})
|
||||
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
@ -111,7 +111,6 @@ export type CommonNodeType<T = {}> = {
|
||||
subscription_id?: string
|
||||
provider_id?: string
|
||||
_dimmed?: boolean
|
||||
_pluginInstallLocked?: boolean
|
||||
} & T & Partial<PluginDefaultValue>
|
||||
|
||||
export type CommonEdgeType = {
|
||||
|
||||
67
web/app/components/workflow/utils/plugin-install-check.ts
Normal file
67
web/app/components/workflow/utils/plugin-install-check.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { TriggerWithProvider } from '../block-selector/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import type { CommonNodeType, ToolWithProvider } from '../types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
export type PluginInstallCheckContext = {
|
||||
builtInTools?: ToolWithProvider[]
|
||||
customTools?: ToolWithProvider[]
|
||||
workflowTools?: ToolWithProvider[]
|
||||
mcpTools?: ToolWithProvider[]
|
||||
triggerPlugins?: TriggerWithProvider[]
|
||||
dataSourceList?: ToolWithProvider[]
|
||||
}
|
||||
|
||||
export function isNodePluginMissing(
|
||||
data: CommonNodeType,
|
||||
context: PluginInstallCheckContext,
|
||||
): boolean {
|
||||
switch (data.type as BlockEnum) {
|
||||
case BlockEnum.Tool: {
|
||||
const toolData = data as ToolNodeType
|
||||
const collectionMap: Partial<Record<CollectionType, ToolWithProvider[] | undefined>> = {
|
||||
[CollectionType.builtIn]: context.builtInTools,
|
||||
[CollectionType.custom]: context.customTools,
|
||||
[CollectionType.workflow]: context.workflowTools,
|
||||
[CollectionType.mcp]: context.mcpTools,
|
||||
}
|
||||
const collection = collectionMap[toolData.provider_type]
|
||||
if (!collection)
|
||||
return false
|
||||
const matched = collection.find(t =>
|
||||
(toolData.plugin_id && t.plugin_id === toolData.plugin_id)
|
||||
|| canFindTool(t.id, toolData.provider_id)
|
||||
|| t.name === toolData.provider_name,
|
||||
)
|
||||
return !matched && Boolean(toolData.plugin_unique_identifier)
|
||||
}
|
||||
case BlockEnum.TriggerPlugin: {
|
||||
const triggerData = data as PluginTriggerNodeType
|
||||
if (!context.triggerPlugins)
|
||||
return false
|
||||
const matched = context.triggerPlugins.find(p =>
|
||||
p.name === triggerData.provider_name
|
||||
|| p.id === triggerData.provider_id
|
||||
|| (triggerData.plugin_id && p.plugin_id === triggerData.plugin_id),
|
||||
)
|
||||
return !matched && Boolean(triggerData.plugin_unique_identifier)
|
||||
}
|
||||
case BlockEnum.DataSource: {
|
||||
const dsData = data as DataSourceNodeType
|
||||
if (!context.dataSourceList)
|
||||
return false
|
||||
const matched = context.dataSourceList.find(item =>
|
||||
(dsData.plugin_unique_identifier && item.plugin_unique_identifier === dsData.plugin_unique_identifier)
|
||||
|| (dsData.plugin_id && item.plugin_id === dsData.plugin_id)
|
||||
|| (dsData.provider_name && item.provider === dsData.provider_name),
|
||||
)
|
||||
return !matched && Boolean(dsData.plugin_unique_identifier)
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user