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:
yyh
2026-01-30 01:30:57 +08:00
parent 1a51f52061
commit 464b92da32
9 changed files with 89 additions and 68 deletions

View File

@ -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'}
`}

View File

@ -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}
/>
)
}

View File

@ -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

View File

@ -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

View File

@ -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()