From 4eda412f3c424123210e8d68278c0e845b11f22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 17:27:44 -0300 Subject: [PATCH 1/2] perf(graphical-editor): scope per-edit scans + batch hot-path store writes (DOPE-491) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - syncNodesWithVariables(FBD): batched updateNodes callback (one store commit per sweep) + optional rungId scoping; ladder add/drag-stop are rung-scoped, remove stays flow-scoped (block removal can delete variables referenced by other rungs); variable-table edits, project open and save relink keep the full sweep - getFBDPouVariablesRungNodeAndEdges: WeakMap-cached per-rung lookups (node-by-id, edges-by-source/target) + variable name index — per-node render cost drops from O(nodes+edges) to O(1) amortized - ladder/FBD debug styling: pure state computation with adjacency maps, deps narrowed to pouType + hasProgramInstance, content-stable guard so polls that don't change a rung keep styledNodes/styledEdges identity - FBD mouse tracking: state -> refs read at paste time; no re-renders on canvas mouse travel - debug poll: single setDebugValues commit per poll cycle (replaces setDebugBoolValues + setDebugNonBoolValues) Mirror of openplc-web fix/dope-491-hot-path-scans Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- .../graphical-editor/fbd/utils/utils.ts | 133 ++++-- .../fbd/fbd-utils/useCopyPaste.ts | 18 +- .../_molecules/graphical-editor/fbd/index.tsx | 308 +++++++------ .../graphical-editor/ladder/rung/body.tsx | 428 ++++++++++-------- .../variables-table/selectable-cell.tsx | 8 +- .../_organisms/variables-editor/index.tsx | 8 +- src/frontend/hooks/use-content-stable.ts | 24 + src/frontend/hooks/useDebugPolling.ts | 7 +- src/frontend/services/save-actions.ts | 6 +- .../store/__tests__/fbd-slice.test.ts | 36 ++ .../store/__tests__/ladder-slice.test.ts | 46 ++ .../store/__tests__/workspace-slice.test.ts | 40 +- src/frontend/store/slices/fbd/slice.ts | 16 + src/frontend/store/slices/fbd/types.ts | 2 + src/frontend/store/slices/ladder/slice.ts | 19 + src/frontend/store/slices/ladder/types.ts | 2 + src/frontend/store/slices/shared/slice.ts | 8 +- src/frontend/store/slices/workspace/slice.ts | 12 +- src/frontend/store/slices/workspace/types.ts | 4 +- .../sync-nodes-with-variables.test.ts | 242 ++++++---- .../graphical/sync-nodes-with-variables.ts | 189 ++++---- 21 files changed, 965 insertions(+), 591 deletions(-) create mode 100644 src/frontend/hooks/use-content-stable.ts diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts index 535694e28..2ca409646 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts @@ -11,6 +11,102 @@ import { buildHandle } from '../handle' import { DEFAULT_BLOCK_CONNECTOR_Y, DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, DEFAULT_BLOCK_WIDTH } from './constants' import type { BasicNodeData } from './types' +type FBDRung = FBDFlowType['rung'] +type FBDRungNode = FBDRung['nodes'][0] +type FBDRungEdge = FBDRung['edges'][0] + +type RungLookups = { + nodeById: Map + edgesBySource: Map + edgesByTarget: Map +} + +// Per-rung lookup tables, cached on the rung's (immutable) identity. This +// util runs during every FBD node render; the previous linear scans made a +// render pass O(nodes × (nodes + edges)). Immer replaces the rung object on +// any change, so a stale entry can never be served. +const rungLookupsCache = new WeakMap() + +const getRungLookups = (rung: FBDRung): RungLookups => { + let lookups = rungLookupsCache.get(rung) + if (!lookups) { + lookups = { + nodeById: new Map(), + edgesBySource: new Map(), + edgesByTarget: new Map(), + } + for (const node of rung.nodes) { + if (!lookups.nodeById.has(node.id)) lookups.nodeById.set(node.id, node) + } + for (const edge of rung.edges) { + const bySource = lookups.edgesBySource.get(edge.source) + if (bySource) bySource.push(edge) + else lookups.edgesBySource.set(edge.source, [edge]) + const byTarget = lookups.edgesByTarget.get(edge.target) + if (byTarget) byTarget.push(edge) + else lookups.edgesByTarget.set(edge.target, [edge]) + } + rungLookupsCache.set(rung, lookups) + } + return lookups +} + +// Variable names are unique per POU (case-insensitive, enforced by the +// variables table), so a first-wins lowercase index matches `find` exactly. +const variablesByNameCache = new WeakMap>() + +const getVariablesByName = (variables: PLCVariable[]): Map => { + let byName = variablesByNameCache.get(variables) + if (!byName) { + byName = new Map() + for (const variable of variables) { + const key = variable.name.toLowerCase() + if (!byName.has(key)) byName.set(key, variable) + } + variablesByNameCache.set(variables, byName) + } + return byName +} + +const EMPTY_EDGES: FBDRungEdge[] = [] + +const selectNodeVariable = ( + node: FBDRungNode, + variables: PLCVariable[], + variableName: string | undefined, +): PLCVariable | undefined => { + const byName = getVariablesByName(variables) + + const findByNodeVarOrFallback = (): PLCVariable | undefined => { + const nodeVarName = (node.data as BasicNodeData).variable.name + if (nodeVarName !== undefined) return byName.get(nodeVarName.toLowerCase()) + if (variableName === undefined) return undefined + const candidate = byName.get(variableName.toLowerCase()) + return candidate?.name === variableName ? candidate : undefined + } + + switch (node.type as keyof typeof customNodeTypes) { + case 'block': { + const nodeVarName = (node.data as BasicNodeData).variable.name + return nodeVarName !== undefined ? byName.get(nodeVarName.toLowerCase()) : undefined + } + case 'connector': + case 'continuation': + case 'comment': + return undefined + case 'input-variable': + case 'output-variable': + case 'inout-variable': + // Variable nodes - allow all types including derived (user-defined types) + return findByNodeVarOrFallback() + default: { + // Other node types - only allow base types (not derived/user-defined) + const candidate = findByNodeVarOrFallback() + return candidate && candidate.type.definition !== 'derived' ? candidate : undefined + } + } +} + // `pouName` is the bound POU for the caller's editor instance (from // `useBoundPou()` under multi-mount, or the active editor's name for // legacy single-mount call sites). Taking it as a string instead of @@ -34,38 +130,11 @@ export const getFBDPouVariablesRungNodeAndEdges = ( } => { const pou = pous.find((pou) => pou.name === pouName) const rung = fbdFlows.find((flow) => flow.name === pouName)?.rung - const node = rung?.nodes.find((node) => node.id === data.nodeId) + const lookups = rung ? getRungLookups(rung) : undefined + const node = lookups?.nodeById.get(data.nodeId) const variables: PLCVariable[] = pou?.interface?.variables ?? [] - let variable = variables.find((variable) => { - if (!node) return undefined - switch (node.type as keyof typeof customNodeTypes) { - case 'block': - return ( - (node.data as BasicNodeData).variable.name !== undefined && - (node.data as BasicNodeData).variable.name.toLowerCase() === variable.name.toLowerCase() - ) - case 'connector': - case 'continuation': - return undefined - case 'comment': - return undefined - case 'input-variable': - case 'output-variable': - case 'inout-variable': - // Variable nodes - allow all types including derived (user-defined types) - return (node.data as BasicNodeData).variable.name !== undefined - ? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase() - : variable.name === data.variableName - default: - // Other node types - only allow base types (not derived/user-defined) - return ( - ((node.data as BasicNodeData).variable.name !== undefined - ? variable.name.toLowerCase() === (node.data as BasicNodeData).variable.name.toLowerCase() - : variable.name === data.variableName) && variable.type.definition !== 'derived' - ) - } - }) + let variable = node && variables.length > 0 ? selectNodeVariable(node, variables, data.variableName) : undefined // Fallback: try to resolve as array element access (e.g. "Sensor[0]") if (!variable && node) { @@ -88,8 +157,8 @@ export const getFBDPouVariablesRungNodeAndEdges = ( } } - const edgesThatNodeIsSource = rung?.edges.filter((edge) => edge.source === data.nodeId) - const edgesThatNodeIsTarget = rung?.edges.filter((edge) => edge.target === data.nodeId) + const edgesThatNodeIsSource = lookups ? (lookups.edgesBySource.get(data.nodeId) ?? EMPTY_EDGES) : undefined + const edgesThatNodeIsTarget = lookups ? (lookups.edgesByTarget.get(data.nodeId) ?? EMPTY_EDGES) : undefined return { pou, diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts index 35b4f308f..360e063d5 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts +++ b/src/frontend/components/_molecules/graphical-editor/fbd/fbd-utils/useCopyPaste.ts @@ -13,15 +13,20 @@ import { } from '../../../../_features/[workspace]/editor/graphical/active-context' export const useFBDClipboard = ({ - mousePosition, - insideViewport, + mousePositionRef, + insideViewportRef, reactFlowInstance, rung, viewportRef, handleDeleteNodes, }: { - mousePosition: { x: number; y: number } - insideViewport: boolean + /** + * Refs, not values: mouse position and hover state change on every pointer + * move, and they're only read at paste time — passing them as refs keeps + * the FBD container from re-rendering while the mouse travels the canvas. + */ + mousePositionRef: RefObject<{ x: number; y: number }> + insideViewportRef: RefObject reactFlowInstance: ReactFlowInstance | null rung: FBDRungState /** @@ -172,8 +177,9 @@ export const useFBDClipboard = ({ return } + const mousePosition = mousePositionRef.current ?? { x: 0, y: 0 } const nodePosition: XYPosition = reactFlowInstance - ? insideViewport + ? insideViewportRef.current ? reactFlowInstance.screenToFlowPosition({ x: mousePosition.x, y: mousePosition.y, @@ -214,7 +220,7 @@ export const useFBDClipboard = ({ }) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [isActive, insideViewport, mousePosition, reactFlowInstance, fbdFlowActions, rung], + [isActive, reactFlowInstance, fbdFlowActions, rung], ) useEffect(() => { diff --git a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx index 2c5eb38bf..4739e3bd8 100644 --- a/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx +++ b/src/frontend/components/_molecules/graphical-editor/fbd/index.tsx @@ -16,6 +16,8 @@ import { import { debounce, isEqual } from 'lodash' import { DragEvent, MouseEvent, useEffect, useMemo, useRef, useState } from 'react' +import type { PLCVariable } from '../../../../../middleware/shared/ports/types' +import { mapsEqual, useContentStable } from '../../../../hooks/use-content-stable' import { useDebugCompositeKey } from '../../../../hooks/use-debug-composite-key' import { useDebugBoolValuesMap, @@ -54,65 +56,38 @@ const SNAP_GRID: SnapGrid = [16, 16] const PRO_OPTIONS = { hideAttribution: true } const CONTROLS_CONFIG = { showInteractive: false } -export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false }: FBDProps) => { - // Bound POU + editor model — every multi-mounted FBDBody reads - // its OWN POU from the `GraphicalEditorActiveProvider` so cross- - // tab store mutations don't fire effects against the wrong flow. - const pouName = useBoundPou() - const editor = useBoundEditorModel() - const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) - const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) - const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) - const { closeModal, openModal } = useOpenPLCStore((state) => state.modalActions) - const blockElementModal = useOpenPLCStore((state) => state.modals['block-fbd-element']) - const pous = useOpenPLCStore((state) => state.project.data.pous) - const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) - const isDebuggerVisible = useIsDebuggerVisible() - const debugVariableValues = useDebugBoolValuesMap() - const debugForcedVariables = useDebugForcedVariablesMap() - const { captureAndPush } = usePouSnapshot() - - const pouRef = pous.find((pou) => pou.name === pouName) - const getCompositeKey = useDebugCompositeKey() - const [rungLocal, setRungLocal] = useState(rung) - const [dragging, setDragging] = useState(false) - - const [reactFlowInstance, setReactFlowInstance] = useState(null) - const reactFlowViewportRef = useRef(null) - - const [insideViewport, setInsideViewport] = useState(false) - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) - useFBDClipboard({ - mousePosition, - insideViewport, - reactFlowInstance, - rung, - viewportRef: reactFlowViewportRef, - handleDeleteNodes: (nodes, edges) => { - handleOnDelete(nodes, edges) - }, - }) +// --- Debug edge coloring --- - const nodeTypes = useMemo(() => customNodeTypes, []) - const canZoom = useMemo(() => { - if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { - return editor.graphical.canEditorZoom - } - return false - }, [editor]) - const canPan = useMemo(() => { - if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { - return editor.graphical.canEditorPan - } - return false - }, [editor]) +type FBDDebugContext = { + isFunctionBlockPou: boolean + hasProgramInstance: boolean + getCompositeKey: (variableName: string) => string + boolValues: Map + forcedValues: Map + pouVariables: PLCVariable[] | undefined +} - // --- Debug edge coloring and node lockdown --- +const computeFBDEdgeStates = ( + nodes: FBDRungState['nodes'], + edges: FBDRungState['edges'], + ctx: FBDDebugContext, +): Map => { + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + const edgesByTarget = new Map() + for (const edge of edges) { + const list = edgesByTarget.get(edge.target) + if (list) list.push(edge) + else edgesByTarget.set(edge.target, [edge]) + } + const variablesByName = new Map() + for (const variable of ctx.pouVariables ?? []) { + const key = variable.name.toLowerCase() + if (!variablesByName.has(key)) variablesByName.set(key, variable) + } const getNodeOutputState = (nodeId: string, sourceHandle: string | null | undefined): boolean | undefined => { - if (!isDebuggerVisible) return undefined - - const node = rungLocal.nodes.find((n) => n.id === nodeId) + const node = nodeById.get(nodeId) if (!node) return undefined if (node.type === 'input-variable' || node.type === 'output-variable' || node.type === 'inout-variable') { @@ -120,23 +95,19 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } const variableName = variableData.variable?.name if (!variableName) return undefined - if (!pouRef) return undefined - const variable = (pouRef.interface?.variables ?? []).find( - (v) => v.name.toLowerCase() === variableName.toLowerCase(), - ) + const variable = variablesByName.get(variableName.toLowerCase()) if (!variable || variable.type.value.toUpperCase() !== 'BOOL') return undefined - const compositeKey = getCompositeKey(variableName) + const compositeKey = ctx.getCompositeKey(variableName) - if (debugForcedVariables.has(compositeKey)) { - return debugForcedVariables.get(compositeKey) + if (ctx.forcedValues.has(compositeKey)) { + return ctx.forcedValues.get(compositeKey) } - const value = debugVariableValues.get(compositeKey) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } if (node.type === 'block') { @@ -146,10 +117,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } } if (!sourceHandle) return undefined - if (pouRef?.pouType !== 'function-block') { - const programInstance = resourceInstances.find((inst: { program: string }) => inst.program === pouName) - if (!programInstance) return undefined - } + if (!ctx.isFunctionBlockPou && !ctx.hasProgramInstance) return undefined const outputVariable = blockData.variant?.variables.find((v) => v.name === sourceHandle) if (!outputVariable || outputVariable.type.value.toUpperCase() !== 'BOOL') return undefined @@ -158,27 +126,23 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } const blockVariableName = blockData.variable?.name if (!blockVariableName) return undefined - const outputVariableName = `${blockVariableName}.${sourceHandle}` - const compositeKey = getCompositeKey(outputVariableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`${blockVariableName}.${sourceHandle}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } else if (blockData.variant?.type === 'function') { const blockName = blockData.variant.name.toUpperCase() const numericId = (node.data as { numericId?: string }).numericId if (!numericId) return undefined - const tempVarName = `_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}` - const compositeKey = getCompositeKey(tempVarName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } return undefined @@ -187,59 +151,152 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } return undefined } - const styledEdges = useMemo(() => { - if (!isDebuggerVisible) { - return rungLocal.edges + const isPassThroughNode = (node: FBDRungState['nodes'][number]): boolean => { + return node.type === 'connector' || node.type === 'continuation' + } + + const edgeStates = new Map() + + const determineEdgeState = (edgeId: string, visited: Set): boolean => { + if (edgeStates.has(edgeId)) { + return edgeStates.get(edgeId)! } - const edgeStateMap = new Map() + if (visited.has(edgeId)) { + return false + } + visited.add(edgeId) - const isPassThroughNode = (node: (typeof rungLocal.nodes)[number]): boolean => { - return node.type === 'connector' || node.type === 'continuation' + const edge = edgeById.get(edgeId) + if (!edge) { + visited.delete(edgeId) + return false } - const determineEdgeState = (edgeId: string, visited: Set = new Set()): boolean => { - if (edgeStateMap.has(edgeId)) { - return edgeStateMap.get(edgeId)! - } + const sourceNode = nodeById.get(edge.source) + if (!sourceNode) { + visited.delete(edgeId) + return false + } - if (visited.has(edgeId)) { - return false - } - visited.add(edgeId) + const incomingEdges = edgesByTarget.get(edge.source) ?? [] + const isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id, visited)) - const edge = rungLocal.edges.find((e) => e.id === edgeId) - if (!edge) { - visited.delete(edgeId) - return false - } + const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle) - const sourceNode = rungLocal.nodes.find((n) => n.id === edge.source) - if (!sourceNode) { - visited.delete(edgeId) - return false - } + const isGreen = isPassThroughNode(sourceNode) ? isInputGreen : sourceOutputState === true + + edgeStates.set(edgeId, isGreen) + visited.delete(edgeId) + return isGreen + } - const incomingEdges = rungLocal.edges.filter((e) => e.target === edge.source) - const isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id, visited)) + edges.forEach((edge) => { + determineEdgeState(edge.id, new Set()) + }) - const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle) + return edgeStates +} - const isGreen = isPassThroughNode(sourceNode) ? isInputGreen : sourceOutputState === true +const fbdEdgeStatesEqual = (previous: Map | null, next: Map | null): boolean => + previous !== null && next !== null && mapsEqual(previous, next) - edgeStateMap.set(edgeId, isGreen) - visited.delete(edgeId) - return isGreen +export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false }: FBDProps) => { + // Bound POU + editor model — every multi-mounted FBDBody reads + // its OWN POU from the `GraphicalEditorActiveProvider` so cross- + // tab store mutations don't fire effects against the wrong flow. + const pouName = useBoundPou() + const editor = useBoundEditorModel() + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const { closeModal, openModal } = useOpenPLCStore((state) => state.modalActions) + const blockElementModal = useOpenPLCStore((state) => state.modals['block-fbd-element']) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const hasProgramInstance = useOpenPLCStore((state) => + state.project.data.configurations.resource.instances.some((instance) => instance.program === pouName), + ) + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() + const debugForcedVariables = useDebugForcedVariablesMap() + const { captureAndPush } = usePouSnapshot() + + const pouRef = pous.find((pou) => pou.name === pouName) + const getCompositeKey = useDebugCompositeKey() + const [rungLocal, setRungLocal] = useState(rung) + const [dragging, setDragging] = useState(false) + + const [reactFlowInstance, setReactFlowInstance] = useState(null) + const reactFlowViewportRef = useRef(null) + + // Refs, not state: the values are only read inside the paste handler, and + // state here re-rendered the whole FBDBody on every pointer move over the + // canvas (~75 commits/s of pure overhead while idle). + const insideViewportRef = useRef(false) + const mousePositionRef = useRef({ x: 0, y: 0 }) + useFBDClipboard({ + mousePositionRef, + insideViewportRef, + reactFlowInstance, + rung, + viewportRef: reactFlowViewportRef, + handleDeleteNodes: (nodes, edges) => { + handleOnDelete(nodes, edges) + }, + }) + + const nodeTypes = useMemo(() => customNodeTypes, []) + const canZoom = useMemo(() => { + if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { + return editor.graphical.canEditorZoom + } + return false + }, [editor]) + const canPan = useMemo(() => { + if (editor.type === 'plc-graphical' && editor.graphical.language === 'fbd') { + return editor.graphical.canEditorPan } + return false + }, [editor]) - rungLocal.edges.forEach((edge) => { - determineEdgeState(edge.id, new Set()) - }) + // --- Debug edge coloring and node lockdown --- - return rungLocal.edges.map((edge) => { - const isGreen = edgeStateMap.get(edge.id) + const debugEdgeStates = useMemo( + () => + isDebuggerVisible + ? computeFBDEdgeStates(rungLocal.nodes, rungLocal.edges, { + isFunctionBlockPou: pouRef?.pouType === 'function-block', + hasProgramInstance, + getCompositeKey, + boolValues: debugVariableValues, + forcedValues: debugForcedVariables, + pouVariables: pouRef?.interface?.variables, + }) + : null, + [ + isDebuggerVisible, + rungLocal.nodes, + rungLocal.edges, + pouRef?.pouType, + hasProgramInstance, + getCompositeKey, + debugVariableValues, + debugForcedVariables, + pouRef?.interface?.variables, + ], + ) + + // Identity-stable across polls that didn't change this flow's edge states, + // so styledEdges keeps its identity and the canvas skips re-render. + const stableDebugEdgeStates = useContentStable(debugEdgeStates, fbdEdgeStatesEqual) + + const styledEdges = useMemo(() => { + if (!stableDebugEdgeStates) { + return rungLocal.edges + } - if (isGreen === true) { + return rungLocal.edges.map((edge) => { + if (stableDebugEdgeStates.get(edge.id) === true) { return { ...edge, style: { stroke: EDGE_COLOR_TRUE, strokeWidth: 2 }, @@ -248,16 +305,7 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } return edge }) - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - debugVariableValues, - debugForcedVariables, - pouName, - pouRef?.interface?.variables, - resourceInstances, - ]) + }, [rungLocal.edges, stableDebugEdgeStates]) const styledNodes = useMemo(() => { if (isDebuggerActive) { @@ -705,14 +753,14 @@ export const FBDBody = ({ rung, nodeDivergences = [], isDebuggerActive = false } className='h-full w-full rounded-lg border p-1 dark:border-neutral-800' ref={reactFlowViewportRef} onMouseEnter={() => { - setInsideViewport(true) + insideViewportRef.current = true }} onMouseLeave={() => { - setInsideViewport(false) - setMousePosition({ x: 0, y: 0 }) + insideViewportRef.current = false + mousePositionRef.current = { x: 0, y: 0 } }} onMouseMove={(event) => { - setMousePosition({ x: event.clientX, y: event.clientY }) + mousePositionRef.current = { x: event.clientX, y: event.clientY } }} > {} -export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { - const pouName = useBoundPou() - const editor = useBoundEditorModel() - const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) - const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) - const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) - const openModal = useOpenPLCStore((state) => state.modalActions.openModal) - const searchQuery = useOpenPLCStore((state) => state.searchQuery) - const setSearchNodePosition = useOpenPLCStore((state) => state.searchActions.setSearchNodePosition) - const pous = useOpenPLCStore((state) => state.project.data.pous) - const resourceInstances = useOpenPLCStore((state) => state.project.data.configurations.resource.instances) - const isDebuggerVisible = useIsDebuggerVisible() - const debugVariableValues = useDebugBoolValuesMap() - - const { captureAndPush } = usePouSnapshot() - const pouRef = pous.find((pou) => pou.name === pouName) - const getCompositeKey = useDebugCompositeKey() - const nodeTypes = useMemo(() => customNodeTypes, []) - - const [rungLocal, setRungLocal] = useState(rung) - const [dragging, setDragging] = useState(false) - - const [reactFlowInstance, setReactFlowInstance] = useState(null) - const reactFlowViewportRef = useRef(null) - - /** - * -- Which means, by default, the flow panel extent is: - * minX: 0 | minY: 0 - * maxX: 1530 | maxY: 200 - */ - const [reactFlowPanelExtent, setReactFlowPanelExtent] = useState([ - [0, 0], - (rung?.reactFlowViewport as [number, number]) ?? [1530, 200], - ]) +// --- Debug edge coloring --- - /** - * Update flow panel extent based on the bounds of the nodes - * To make the getNodesBounds function work, the nodes must have width and height properties set in the node data - * This useEffect will run every time the nodes array changes (i.e. when a node is added or removed) - */ - const updateReactFlowPanelExtent = (rung: RungLadderState) => { - const zeroPositionNode: FlowNode = { - id: '-1', - position: { x: 0, y: 0 }, - data: { label: 'Node 0' }, - width: 150, - height: 40, - } - const bounds = getNodesBounds([zeroPositionNode, ...rung.nodes]) - const [defaultWidth, defaultHeight] = rung.defaultBounds +type LadderDebugContext = { + isFunctionBlockPou: boolean + hasProgramInstance: boolean + getCompositeKey: (variableName: string) => string + boolValues: Map +} - // If the bounds are less than the default extent, set the panel extent to the default extent - if (bounds.width < defaultWidth) bounds.width = defaultWidth - if (bounds.height < defaultHeight) bounds.height = defaultHeight +type RungDebugStates = { + edgeStates: Map + nodeInputStates: Map +} - setReactFlowPanelExtent((prev) => - prev[1][0] === bounds.width && prev[1][1] === bounds.height + 20 - ? prev - : [ - [0, 0], - [bounds.width, bounds.height + 20], - ], - ) - ladderFlowActions.updateReactFlowViewport({ - editorName: pouName, - rungId: rungLocal.id, - reactFlowViewport: [bounds.width, bounds.height + 20], - }) +const computeRungDebugStates = ( + nodes: RungLadderState['nodes'], + edges: RungLadderState['edges'], + ctx: LadderDebugContext, +): RungDebugStates => { + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + const edgesByTarget = new Map() + for (const edge of edges) { + const list = edgesByTarget.get(edge.target) + if (list) list.push(edge) + else edgesByTarget.set(edge.target, [edge]) } - // --- Debug edge coloring and node lockdown --- - const getNodeOutputState = ( nodeId: string, sourceHandle: string | null | undefined, isInputGreen: boolean, ): boolean | undefined => { - if (!isDebuggerVisible) return undefined - - const node = rungLocal.nodes.find((n) => n.id === nodeId) + const node = nodeById.get(nodeId) if (!node) return undefined if (node.type === 'powerRail') { @@ -180,12 +135,12 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi const variableName = contactData.variable?.name if (!variableName) return undefined - const compositeKey = getCompositeKey(variableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(variableName) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - const contactState = (node.data as { variant: 'open' | 'negated' }).variant === 'negated' ? !isTrue : isTrue + const contactState = contactData.variant === 'negated' ? !isTrue : isTrue return isInputGreen && contactState } @@ -202,36 +157,29 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } if (!sourceHandle) return undefined - if (pouRef?.pouType !== 'function-block') { - const programInstance = resourceInstances.find((inst) => inst.program === pouName) - if (!programInstance) return undefined - } + if (!ctx.isFunctionBlockPou && !ctx.hasProgramInstance) return undefined if (blockData.variant?.type === 'function-block') { const blockVariableName = blockData.variable?.name if (!blockVariableName) return undefined - const outputVariableName = `${blockVariableName}.${sourceHandle}` - const compositeKey = getCompositeKey(outputVariableName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`${blockVariableName}.${sourceHandle}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } else if (blockData.variant?.type === 'function') { const blockName = blockData.variant.name.toUpperCase() const numericId = blockData.numericId if (!numericId) return undefined - const tempVarName = `_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}` - const compositeKey = getCompositeKey(tempVarName) - const value = debugVariableValues.get(compositeKey) + const compositeKey = ctx.getCompositeKey(`_TMP_${blockName}${numericId}_${sourceHandle.toUpperCase()}`) + const value = ctx.boolValues.get(compositeKey) if (value === undefined) return undefined - const isTrue = value === '1' || value.toUpperCase() === 'TRUE' - return isTrue + return value === '1' || value.toUpperCase() === 'TRUE' } return undefined @@ -240,46 +188,189 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi return undefined } - const styledEdges = useMemo(() => { - if (!isDebuggerVisible) { - return rungLocal.edges + const edgeStates = new Map() + + const determineEdgeState = (edgeId: string): boolean => { + if (edgeStates.has(edgeId)) { + return edgeStates.get(edgeId)! } - const edgeStateMap = new Map() + const edge = edgeById.get(edgeId) + if (!edge) return false - const determineEdgeState = (edgeId: string): boolean => { - if (edgeStateMap.has(edgeId)) { - return edgeStateMap.get(edgeId)! - } + const incomingEdges = edgesByTarget.get(edge.source) ?? [] - const edge = rungLocal.edges.find((e) => e.id === edgeId) - if (!edge) return false + let isInputGreen = false + if (incomingEdges.length === 0) { + const sourceNode = nodeById.get(edge.source) + isInputGreen = sourceNode?.type === 'powerRail' && (sourceNode.data as { variant: string }).variant === 'left' + } else { + isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id)) + } - const incomingEdges = rungLocal.edges.filter((e) => e.target === edge.source) + const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle, isInputGreen) - let isInputGreen = false - if (incomingEdges.length === 0) { - const sourceNode = rungLocal.nodes.find((n) => n.id === edge.source) - isInputGreen = sourceNode?.type === 'powerRail' && (sourceNode.data as { variant: string }).variant === 'left' - } else { - isInputGreen = incomingEdges.some((incomingEdge) => determineEdgeState(incomingEdge.id)) - } + const isGreen = sourceOutputState === true + edgeStates.set(edgeId, isGreen) + return isGreen + } - const sourceOutputState = getNodeOutputState(edge.source, edge.sourceHandle, isInputGreen) + edges.forEach((edge) => { + determineEdgeState(edge.id) + }) + + const nodeInputStates = new Map() + + const determineNodeInputState = (nodeId: string): boolean => { + if (nodeInputStates.has(nodeId)) { + return nodeInputStates.get(nodeId)! + } + + const node = nodeById.get(nodeId) + if (!node) return false - const isGreen = sourceOutputState === true - edgeStateMap.set(edgeId, isGreen) - return isGreen + if (node.type === 'powerRail' && (node.data as { variant: string }).variant === 'left') { + nodeInputStates.set(nodeId, true) + return true } - rungLocal.edges.forEach((edge) => { - determineEdgeState(edge.id) + const incomingEdges = edgesByTarget.get(nodeId) ?? [] + + if (incomingEdges.length === 0) { + nodeInputStates.set(nodeId, false) + return false + } + + const hasGreenInput = incomingEdges.some((incomingEdge) => { + const sourceInputGreen = determineNodeInputState(incomingEdge.source) + const sourceOutputGreen = getNodeOutputState(incomingEdge.source, incomingEdge.sourceHandle, sourceInputGreen) + return sourceOutputGreen === true }) - return rungLocal.edges.map((edge) => { - const isGreen = edgeStateMap.get(edge.id) + nodeInputStates.set(nodeId, hasGreenInput) + return hasGreenInput + } + + nodes.forEach((node) => { + determineNodeInputState(node.id) + }) + + return { edgeStates, nodeInputStates } +} + +const rungDebugStatesEqual = (previous: RungDebugStates | null, next: RungDebugStates | null): boolean => + previous !== null && + next !== null && + mapsEqual(previous.edgeStates, next.edgeStates) && + mapsEqual(previous.nodeInputStates, next.nodeInputStates) + +export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActive = false }: RungBodyProps) => { + const pouName = useBoundPou() + const editor = useBoundEditorModel() + const ladderFlowActions = useOpenPLCStore((state) => state.ladderFlowActions) + const updateModelVariables = useOpenPLCStore((state) => state.editorActions.updateModelVariables) + const deleteVariable = useOpenPLCStore((state) => state.projectActions.deleteVariable) + const openModal = useOpenPLCStore((state) => state.modalActions.openModal) + const searchQuery = useOpenPLCStore((state) => state.searchQuery) + const setSearchNodePosition = useOpenPLCStore((state) => state.searchActions.setSearchNodePosition) + const pous = useOpenPLCStore((state) => state.project.data.pous) + const hasProgramInstance = useOpenPLCStore((state) => + state.project.data.configurations.resource.instances.some((instance) => instance.program === pouName), + ) + const isDebuggerVisible = useIsDebuggerVisible() + const debugVariableValues = useDebugBoolValuesMap() + + const { captureAndPush } = usePouSnapshot() + const pouRef = pous.find((pou) => pou.name === pouName) + const getCompositeKey = useDebugCompositeKey() + const nodeTypes = useMemo(() => customNodeTypes, []) + + const [rungLocal, setRungLocal] = useState(rung) + const [dragging, setDragging] = useState(false) + + const [reactFlowInstance, setReactFlowInstance] = useState(null) + const reactFlowViewportRef = useRef(null) + + /** + * -- Which means, by default, the flow panel extent is: + * minX: 0 | minY: 0 + * maxX: 1530 | maxY: 200 + */ + const [reactFlowPanelExtent, setReactFlowPanelExtent] = useState([ + [0, 0], + (rung?.reactFlowViewport as [number, number]) ?? [1530, 200], + ]) - if (isGreen === true) { + /** + * Update flow panel extent based on the bounds of the nodes + * To make the getNodesBounds function work, the nodes must have width and height properties set in the node data + * This useEffect will run every time the nodes array changes (i.e. when a node is added or removed) + */ + const updateReactFlowPanelExtent = (rung: RungLadderState) => { + const zeroPositionNode: FlowNode = { + id: '-1', + position: { x: 0, y: 0 }, + data: { label: 'Node 0' }, + width: 150, + height: 40, + } + const bounds = getNodesBounds([zeroPositionNode, ...rung.nodes]) + const [defaultWidth, defaultHeight] = rung.defaultBounds + + // If the bounds are less than the default extent, set the panel extent to the default extent + if (bounds.width < defaultWidth) bounds.width = defaultWidth + if (bounds.height < defaultHeight) bounds.height = defaultHeight + + setReactFlowPanelExtent((prev) => + prev[1][0] === bounds.width && prev[1][1] === bounds.height + 20 + ? prev + : [ + [0, 0], + [bounds.width, bounds.height + 20], + ], + ) + ladderFlowActions.updateReactFlowViewport({ + editorName: pouName, + rungId: rungLocal.id, + reactFlowViewport: [bounds.width, bounds.height + 20], + }) + } + + // --- Debug edge coloring and node lockdown --- + + const debugStates = useMemo( + () => + isDebuggerVisible + ? computeRungDebugStates(rungLocal.nodes, rungLocal.edges, { + isFunctionBlockPou: pouRef?.pouType === 'function-block', + hasProgramInstance, + getCompositeKey, + boolValues: debugVariableValues, + }) + : null, + [ + isDebuggerVisible, + rungLocal.nodes, + rungLocal.edges, + pouRef?.pouType, + hasProgramInstance, + getCompositeKey, + debugVariableValues, + ], + ) + + // Identity-stable across polls that didn't change this rung's states, so + // the styled arrays below keep their identity and the rung skips re-render. + const stableDebugStates = useContentStable(debugStates, rungDebugStatesEqual) + + const styledEdges = useMemo(() => { + if (!stableDebugStates) { + return rungLocal.edges + } + + const { edgeStates } = stableDebugStates + return rungLocal.edges.map((edge) => { + if (edgeStates.get(edge.id) === true) { return { ...edge, style: { stroke: EDGE_COLOR_TRUE, strokeWidth: 2 }, @@ -288,75 +379,24 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi return edge }) - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - debugVariableValues, - pouName, - pouRef, - resourceInstances, - getCompositeKey, - ]) + }, [rungLocal.edges, stableDebugStates]) const styledNodes = useMemo(() => { - const baseNodes = !isDebuggerVisible + const baseNodes = !stableDebugStates ? rungLocal.nodes - : (() => { - const nodeInputStateMap = new Map() - - const determineNodeInputState = (nodeId: string): boolean => { - if (nodeInputStateMap.has(nodeId)) { - return nodeInputStateMap.get(nodeId)! - } - - const node = rungLocal.nodes.find((n) => n.id === nodeId) - if (!node) return false - - if (node.type === 'powerRail' && (node.data as { variant: string }).variant === 'left') { - nodeInputStateMap.set(nodeId, true) - return true + : rungLocal.nodes.map((node) => { + if (node.type === 'parallel') { + const isFlowActive = stableDebugStates.nodeInputStates.get(node.id) || false + return { + ...node, + data: { + ...node.data, + isFlowActive, + }, } - - const incomingEdges = rungLocal.edges.filter((e) => e.target === nodeId) - - if (incomingEdges.length === 0) { - nodeInputStateMap.set(nodeId, false) - return false - } - - const hasGreenInput = incomingEdges.some((incomingEdge) => { - const sourceInputGreen = determineNodeInputState(incomingEdge.source) - const sourceOutputGreen = getNodeOutputState( - incomingEdge.source, - incomingEdge.sourceHandle, - sourceInputGreen, - ) - return sourceOutputGreen === true - }) - - nodeInputStateMap.set(nodeId, hasGreenInput) - return hasGreenInput } - - rungLocal.nodes.forEach((node) => { - determineNodeInputState(node.id) - }) - - return rungLocal.nodes.map((node) => { - if (node.type === 'parallel') { - const isFlowActive = nodeInputStateMap.get(node.id) || false - return { - ...node, - data: { - ...node.data, - isFlowActive, - }, - } - } - return node - }) - })() + return node + }) if (isDebuggerActive) { return baseNodes.map((node) => ({ @@ -368,17 +408,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } return baseNodes - }, [ - rungLocal.edges, - rungLocal.nodes, - isDebuggerVisible, - isDebuggerActive, - debugVariableValues, - pouName, - pouRef, - resourceInstances, - getCompositeKey, - ]) + }, [rungLocal.nodes, stableDebugStates, isDebuggerActive]) /** * Update the local rung state when the rung state changes @@ -550,7 +580,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi }) if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables( + pouRef.interface?.variables ?? [], + ladderFlows, + ladderFlowActions.updateNodes, + pouName, + rungLocal.id, + ) } } @@ -625,8 +661,10 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi }) } + // Flow-scoped (no rungId): removing a block can delete its backing + // variable, which may strand bindings in this POU's other rungs. if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNodes, pouName) } } @@ -685,7 +723,13 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } if (pouRef) { - syncNodesWithVariables(pouRef.interface?.variables ?? [], ladderFlows, ladderFlowActions.updateNode, pouName) + syncNodesWithVariables( + pouRef.interface?.variables ?? [], + ladderFlows, + ladderFlowActions.updateNodes, + pouName, + rungLocal.id, + ) } } diff --git a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx index af865d8bd..3e51d1408 100644 --- a/src/frontend/components/_molecules/variables-table/selectable-cell.tsx +++ b/src/frontend/components/_molecules/variables-table/selectable-cell.tsx @@ -52,8 +52,8 @@ const SelectableTypeCell = ({ project: { data: { dataTypes }, }, - ladderFlowActions: { updateNode }, - fbdFlowActions: { updateNode: updateFBDNode }, + ladderFlowActions: { updateNodes }, + fbdFlowActions: { updateNodes: updateFBDNodes }, libraries: sliceLibraries, workspace: { isDebuggerVisible }, } = useOpenPLCStore() @@ -187,11 +187,11 @@ const SelectableTypeCell = ({ const newVars = pou?.interface?.variables ?? [] if (language === 'fbd') { - syncNodesWithVariablesFBD(newVars, freshFBDFlows, updateFBDNode, editor.meta.name) + syncNodesWithVariablesFBD(newVars, freshFBDFlows, updateFBDNodes, editor.meta.name) } if (language === 'ld') { - syncNodesWithVariables(newVars, freshLadderFlows, updateNode, editor.meta.name) + syncNodesWithVariables(newVars, freshLadderFlows, updateNodes, editor.meta.name) } setCellValue(value) diff --git a/src/frontend/components/_organisms/variables-editor/index.tsx b/src/frontend/components/_organisms/variables-editor/index.tsx index eddca458d..e3b650e59 100644 --- a/src/frontend/components/_organisms/variables-editor/index.tsx +++ b/src/frontend/components/_organisms/variables-editor/index.tsx @@ -68,9 +68,9 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl const editor = useOpenPLCStore((s) => selectEditorForPou(s, propName)) const { ladderFlows, - ladderFlowActions: { updateNode }, + ladderFlowActions: { updateNode, updateNodes }, fbdFlows, - fbdFlowActions: { updateNode: updateFBDNode }, + fbdFlowActions: { updateNode: updateFBDNode, updateNodes: updateFBDNodes }, workspace: { systemConfigs: { shouldUseDarkMode }, isDebuggerVisible, @@ -924,11 +924,11 @@ const VariablesEditor = ({ name: propName, isActive: _isActive = true }: Variabl const freshVariables = freshPou?.interface?.variables ?? [] if (language === 'ld') { - syncNodesWithVariablesUtil(freshVariables, freshLadderFlows, updateNode) + syncNodesWithVariablesUtil(freshVariables, freshLadderFlows, updateNodes) } if (language === 'fbd') { - syncNodesWithVariablesFBDUtil(freshVariables, freshFBDFlows, updateFBDNode) + syncNodesWithVariablesFBDUtil(freshVariables, freshFBDFlows, updateFBDNodes) } for (const pair of renamedPairsToPropagate) { diff --git a/src/frontend/hooks/use-content-stable.ts b/src/frontend/hooks/use-content-stable.ts new file mode 100644 index 000000000..5f3fb7b10 --- /dev/null +++ b/src/frontend/hooks/use-content-stable.ts @@ -0,0 +1,24 @@ +import { useRef } from 'react' + +export const mapsEqual = (a: Map, b: Map): boolean => { + if (a === b) return true + if (a.size !== b.size) return false + for (const [key, value] of a) { + if (!b.has(key) || b.get(key) !== value) return false + } + return true +} + +/** + * Returns the previous instance while `isEqual` says the content is + * unchanged, so downstream memos keyed on the value skip recomputation when + * a producer replaces it with an equivalent one (e.g. debug-poll Maps that + * didn't touch this consumer's entries). + */ +export function useContentStable(value: T, isEqual: (previous: T, next: T) => boolean): T { + const ref = useRef(value) + if (ref.current !== value && !isEqual(ref.current, value)) { + ref.current = value + } + return ref.current +} diff --git a/src/frontend/hooks/useDebugPolling.ts b/src/frontend/hooks/useDebugPolling.ts index 31ed497b4..08f263b81 100644 --- a/src/frontend/hooks/useDebugPolling.ts +++ b/src/frontend/hooks/useDebugPolling.ts @@ -390,9 +390,10 @@ export function useDebugPolling({ debugTreesRef }: UseDebugPollingOptions): void itemsProcessed = positionsConsumed } - // Only write to store when values actually changed - if (changedBool.size > 0) workspaceActions.setDebugBoolValues(changedBool) - if (changedNonBool.size > 0) workspaceActions.setDebugNonBoolValues(changedNonBool) + // Only write to store when values actually changed — one commit per poll cycle + if (changedBool.size > 0 || changedNonBool.size > 0) { + workspaceActions.setDebugValues({ boolValues: changedBool, nonBoolValues: changedNonBool }) + } } // Advance offset for next poll cycle (wraps around) diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index def00f254..5daf9e5e7 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -757,14 +757,14 @@ export async function reloadPouFromDisk(pouName: string, projectPort: ProjectPor if (language === 'ld') { const pouFlows = openPLCStoreBase.getState().ladderFlows.filter((f) => f.name === pouName) if (pouFlows.length > 0) { - syncNodesWithVariables(reparsedVars, pouFlows, openPLCStoreBase.getState().ladderFlowActions.updateNode) + syncNodesWithVariables(reparsedVars, pouFlows, openPLCStoreBase.getState().ladderFlowActions.updateNodes) } - // Reset flow updated flag (syncNodesWithVariables triggers updateNode which sets updated=true) + // Reset flow updated flag (syncNodesWithVariables triggers updateNodes which sets updated=true) openPLCStoreBase.getState().ladderFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) } else if (language === 'fbd') { const pouFlows = openPLCStoreBase.getState().fbdFlows.filter((f) => f.name === pouName) if (pouFlows.length > 0) { - syncNodesWithVariablesFBD(reparsedVars, pouFlows, openPLCStoreBase.getState().fbdFlowActions.updateNode) + syncNodesWithVariablesFBD(reparsedVars, pouFlows, openPLCStoreBase.getState().fbdFlowActions.updateNodes) } openPLCStoreBase.getState().fbdFlowActions.setFlowUpdated({ editorName: pouName, updated: false }) } diff --git a/src/frontend/store/__tests__/fbd-slice.test.ts b/src/frontend/store/__tests__/fbd-slice.test.ts index f243fd091..64ba093d5 100644 --- a/src/frontend/store/__tests__/fbd-slice.test.ts +++ b/src/frontend/store/__tests__/fbd-slice.test.ts @@ -264,6 +264,42 @@ describe('createFBDFlowSlice', () => { expect(store.getState().fbdFlows[0].rung.nodes[0].id).toBe('n1') }) + // ------------------------------------------------------------------------- + // updateNodes + // ------------------------------------------------------------------------- + it('updateNodes applies a batch of node replacements and marks the flow as updated', () => { + store.getState().fbdFlowActions.startFBDRung({ editorName: 'editor-1' }) + store.getState().fbdFlowActions.setNodes({ + editorName: 'editor-1', + nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], + }) + + store.getState().fbdFlowActions.updateNodes([ + { editorName: 'editor-1', nodeId: 'n1', node: makeNode({ id: 'n1', data: { label: 'new-1' } }) }, + { editorName: 'editor-1', nodeId: 'n2', node: makeNode({ id: 'n2', data: { label: 'new-2' } }) }, + ]) + + const nodes = store.getState().fbdFlows[0].rung.nodes + expect(nodes.find((n) => n.id === 'n1')?.data.label).toBe('new-1') + expect(nodes.find((n) => n.id === 'n2')?.data.label).toBe('new-2') + expect(store.getState().fbdFlows[0].updated).toBe(true) + }) + + it('updateNodes skips entries whose editor or node does not exist', () => { + store.getState().fbdFlowActions.startFBDRung({ editorName: 'editor-1' }) + store.getState().fbdFlowActions.setNodes({ + editorName: 'editor-1', + nodes: [makeNode({ id: 'n1', data: { label: 'old' } })], + }) + + store.getState().fbdFlowActions.updateNodes([ + { editorName: 'missing-editor', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', nodeId: 'missing', node: makeNode({ id: 'missing' }) }, + ]) + + expect(store.getState().fbdFlows[0].rung.nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + }) + // ------------------------------------------------------------------------- // addNode // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/ladder-slice.test.ts b/src/frontend/store/__tests__/ladder-slice.test.ts index 51ee515f2..4f94e159b 100644 --- a/src/frontend/store/__tests__/ladder-slice.test.ts +++ b/src/frontend/store/__tests__/ladder-slice.test.ts @@ -525,6 +525,52 @@ describe('createLadderFlowSlice', () => { expect(store.getState().ladderFlows[0].updated).toBe(false) }) + // ------------------------------------------------------------------------- + // updateNodes + // ------------------------------------------------------------------------- + it('updateNodes applies a batch of node replacements and marks the flow as updated', () => { + const rung = makeRung({ + nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], + }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store.getState().ladderFlowActions.updateNodes([ + { + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n1', + node: makeNode({ id: 'n1', data: { label: 'new-1' } }), + }, + { + editorName: 'editor-1', + rungId: 'rung-1', + nodeId: 'n2', + node: makeNode({ id: 'n2', data: { label: 'new-2' } }), + }, + ]) + + const nodes = store.getState().ladderFlows[0].rungs[0].nodes + expect(nodes.find((n) => n.id === 'n1')?.data.label).toBe('new-1') + expect(nodes.find((n) => n.id === 'n2')?.data.label).toBe('new-2') + expect(store.getState().ladderFlows[0].updated).toBe(true) + }) + + it('updateNodes skips entries whose editor, rung or node does not exist', () => { + const rung = makeRung({ nodes: [makeNode({ id: 'n1', data: { label: 'old' } })] }) + seedFlowWithRung(store, 'editor-1', rung) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) + + store.getState().ladderFlowActions.updateNodes([ + { editorName: 'missing-editor', rungId: 'rung-1', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', rungId: 'missing-rung', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, + { editorName: 'editor-1', rungId: 'rung-1', nodeId: 'missing', node: makeNode({ id: 'missing' }) }, + ]) + + expect(store.getState().ladderFlows[0].rungs[0].nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + expect(store.getState().ladderFlows[0].updated).toBe(false) + }) + // ------------------------------------------------------------------------- // addNode // ------------------------------------------------------------------------- diff --git a/src/frontend/store/__tests__/workspace-slice.test.ts b/src/frontend/store/__tests__/workspace-slice.test.ts index 976a6461b..d8fedc7f0 100644 --- a/src/frontend/store/__tests__/workspace-slice.test.ts +++ b/src/frontend/store/__tests__/workspace-slice.test.ts @@ -311,26 +311,34 @@ describe('createWorkspaceSlice', () => { expect(store.getState().workspace.debugVariableIndexes).toEqual(indexes) }) - it('setDebugBoolValues merges values into existing map', () => { - const initial = new Map([['var1', 'TRUE']]) - store.getState().workspaceActions.setDebugBoolValues(initial) + it('setDebugValues merges bool and non-bool values into their maps in one commit', () => { + store.getState().workspaceActions.setDebugValues({ + boolValues: new Map([['var1', 'TRUE']]), + nonBoolValues: new Map([['var2', '42']]), + }) expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') - const update = new Map([['var2', 'FALSE']]) - store.getState().workspaceActions.setDebugBoolValues(update) + store.getState().workspaceActions.setDebugValues({ + boolValues: new Map([['var3', 'FALSE']]), + nonBoolValues: new Map([['var4', '3.14']]), + }) expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') - expect(store.getState().workspace.debugBoolValues.get('var2')).toBe('FALSE') + expect(store.getState().workspace.debugBoolValues.get('var3')).toBe('FALSE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') + expect(store.getState().workspace.debugNonBoolValues.get('var4')).toBe('3.14') }) - it('setDebugNonBoolValues merges values into existing map', () => { - const initial = new Map([['var1', '42']]) - store.getState().workspaceActions.setDebugNonBoolValues(initial) - expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') + it('setDebugValues tolerates missing maps', () => { + store.getState().workspaceActions.setDebugValues({ boolValues: new Map([['var1', 'TRUE']]) }) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') - const update = new Map([['var2', '3.14']]) - store.getState().workspaceActions.setDebugNonBoolValues(update) - expect(store.getState().workspace.debugNonBoolValues.get('var1')).toBe('42') - expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('3.14') + store.getState().workspaceActions.setDebugValues({ nonBoolValues: new Map([['var2', '42']]) }) + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') + + store.getState().workspaceActions.setDebugValues({}) + expect(store.getState().workspace.debugBoolValues.get('var1')).toBe('TRUE') + expect(store.getState().workspace.debugNonBoolValues.get('var2')).toBe('42') }) it('setDebugForcedVariables', () => { @@ -444,7 +452,7 @@ describe('createWorkspaceSlice', () => { store.getState().workspaceActions.setDebuggerTargetIp('192.168.0.1') store.getState().workspaceActions.setDebugCContent('code') store.getState().workspaceActions.setDebugVariableIndexes(new Map([['x', 1]])) - store.getState().workspaceActions.setDebugBoolValues(new Map([['x', 'true']])) + store.getState().workspaceActions.setDebugValues({ boolValues: new Map([['x', 'true']]) }) store.getState().workspaceActions.setDebugForcedVariables(new Map([['x', true]])) store.getState().workspaceActions.setDebugTick(100) store @@ -531,7 +539,7 @@ describe('createWorkspaceSlice', () => { it('removeDebugVariable removes from all relevant maps', () => { const key = 'PROGRAM0::myVar' store.getState().workspaceActions.setDebugVariableIndexes(new Map([[key, 5]])) - store.getState().workspaceActions.setDebugNonBoolValues(new Map([[key, '42']])) + store.getState().workspaceActions.setDebugValues({ nonBoolValues: new Map([[key, '42']]) }) store.getState().workspaceActions.setDebugForcedVariables(new Map([[key, true]])) store .getState() diff --git a/src/frontend/store/slices/fbd/slice.ts b/src/frontend/store/slices/fbd/slice.ts index 2d1f97094..4119bd7bc 100644 --- a/src/frontend/store/slices/fbd/slice.ts +++ b/src/frontend/store/slices/fbd/slice.ts @@ -149,6 +149,22 @@ export const createFBDFlowSlice: StateCreator { + for (const { editorName, node, nodeId } of updates) { + const flow = fbdFlows.find((flow) => flow.name === editorName) + if (!flow) continue + + const nodeIndex = flow.rung.nodes.findIndex((n) => n.id === nodeId) + if (nodeIndex === -1) continue + + flow.rung.nodes[nodeIndex] = node + flow.updated = true + } + }), + ) + }, addNode({ editorName, node }) { setState( produce(({ fbdFlows }: FBDFlowState) => { diff --git a/src/frontend/store/slices/fbd/types.ts b/src/frontend/store/slices/fbd/types.ts index 38d3f5848..9bd515b84 100644 --- a/src/frontend/store/slices/fbd/types.ts +++ b/src/frontend/store/slices/fbd/types.ts @@ -61,6 +61,8 @@ type FBDFlowActions = { setNodes: ({ nodes, editorName }: { nodes: Node[]; editorName: string }) => void updateNode: ({ node, nodeId, editorName }: { node: Node; nodeId: string; editorName: string }) => void + /** Batched updateNode: applies every update in a single store commit. */ + updateNodes: (updates: { node: Node; nodeId: string; editorName: string }[]) => void addNode: ({ node, editorName }: { node: Node; editorName: string }) => void removeNodes: ({ nodes, editorName }: { nodes: Node[]; editorName: string }) => void diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index a5aa1059e..c58259f5d 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -311,6 +311,25 @@ export const createLadderFlowSlice: StateCreator { + for (const { editorName, node, nodeId, rungId } of updates) { + const flow = ladderFlows.find((flow) => flow.name === editorName) + if (!flow) continue + + const rung = flow.rungs.find((rung) => rung.id === rungId) + if (!rung) continue + + const nodeIndex = rung.nodes.findIndex((n) => n.id === nodeId) + if (nodeIndex === -1) continue + + rung.nodes[nodeIndex] = node + flow.updated = true + } + }), + ) + }, addNode({ editorName, node, rungId }) { setState( produce(({ ladderFlows }: LadderFlowState) => { diff --git a/src/frontend/store/slices/ladder/types.ts b/src/frontend/store/slices/ladder/types.ts index e90d99f5b..4e5dedfd7 100644 --- a/src/frontend/store/slices/ladder/types.ts +++ b/src/frontend/store/slices/ladder/types.ts @@ -109,6 +109,8 @@ type LadderFlowActions = { * focusing/blurring an element never dirties the POU. */ transient?: boolean }) => void + /** Batched updateNode: applies every update in a single store commit. */ + updateNodes: (updates: { node: Node; nodeId: string; rungId: string; editorName: string }[]) => void addNode: ({ node, rungId, editorName }: { node: Node; rungId: string; editorName: string }) => void removeNodes: ({ nodes, rungId, editorName }: { nodes: Node[]; rungId: string; editorName: string }) => void setSelectedNodes: ({ nodes, rungId, editorName }: { nodes: Node[]; rungId: string; editorName: string }) => void diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 3d1219d18..016c920bb 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -732,8 +732,8 @@ const createSharedSlice: StateCreator = (s const freshLadderFlows = freshState.ladderFlows const freshFBDFlows = freshState.fbdFlows const freshPous = freshState.project.data.pous - const updateLadderNode = freshState.ladderFlowActions.updateNode - const updateFBDNode = freshState.fbdFlowActions.updateNode + const updateLadderNodes = freshState.ladderFlowActions.updateNodes + const updateFBDNodes = freshState.fbdFlowActions.updateNodes try { ladderPous.forEach((pou) => { @@ -743,7 +743,7 @@ const createSharedSlice: StateCreator = (s const pouFlow = freshLadderFlows.filter((flow) => flow.name === pou.name) /* istanbul ignore next -- defensive: flow always exists since we just added it */ if (pouFlow.length > 0) { - syncNodesWithVariables(freshPou.interface?.variables ?? [], pouFlow, updateLadderNode) + syncNodesWithVariables(freshPou.interface?.variables ?? [], pouFlow, updateLadderNodes) } } }) @@ -755,7 +755,7 @@ const createSharedSlice: StateCreator = (s const pouFlow = freshFBDFlows.filter((flow) => flow.name === pou.name) /* istanbul ignore next -- defensive: flow always exists since we just added it */ if (pouFlow.length > 0) { - syncNodesWithVariablesFBD(freshPou.interface?.variables ?? [], pouFlow, updateFBDNode) + syncNodesWithVariablesFBD(freshPou.interface?.variables ?? [], pouFlow, updateFBDNodes) } } }) diff --git a/src/frontend/store/slices/workspace/slice.ts b/src/frontend/store/slices/workspace/slice.ts index 2aeb3d2d1..86f132c1a 100644 --- a/src/frontend/store/slices/workspace/slice.ts +++ b/src/frontend/store/slices/workspace/slice.ts @@ -283,19 +283,13 @@ const createWorkspaceSlice: StateCreator }), ) }, - setDebugBoolValues: (values: Map) => { + setDebugValues: (values: { boolValues?: Map; nonBoolValues?: Map }) => { setState( produce(({ workspace }: WorkspaceSlice) => { - for (const [key, val] of values) { + for (const [key, val] of values.boolValues ?? []) { workspace.debugBoolValues.set(key, val) } - }), - ) - }, - setDebugNonBoolValues: (values: Map) => { - setState( - produce(({ workspace }: WorkspaceSlice) => { - for (const [key, val] of values) { + for (const [key, val] of values.nonBoolValues ?? []) { workspace.debugNonBoolValues.set(key, val) } }), diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index f325db5bd..a9e12ef62 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -171,8 +171,8 @@ export type WorkspaceActions = { setDebuggerTargetIp: (targetIp: string | null) => void setDebugCContent: (content: string | null) => void setDebugVariableIndexes: (indexes: Map) => void - setDebugBoolValues: (values: Map) => void - setDebugNonBoolValues: (values: Map) => void + /** Merges polled values into both maps in a single store commit per poll cycle. */ + setDebugValues: (values: { boolValues?: Map; nonBoolValues?: Map }) => void setDebugForcedVariables: (forced: Map) => void setDebugTick: (tick: number) => void setDebugVariableTree: (tree: Map) => void diff --git a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts index f5aa28d92..1c4d2b2f8 100644 --- a/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts +++ b/src/frontend/utils/graphical/__tests__/sync-nodes-with-variables.test.ts @@ -27,7 +27,7 @@ const makeNode = ( describe('syncNodesWithVariables', () => { it('updates a contact node when the variable type no longer matches BOOL', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -42,10 +42,10 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // When type mismatches, variable id is set to "broken-" to mark it as broken. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'editor1', rungId: 'r1', @@ -57,11 +57,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('does not update a contact node when only the variable id changes but types still match', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -76,17 +76,17 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types match and wrongVariable is not set, so no update is needed. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('treats a POINTER TO variable as compatible with a ULINT expected type', () => { // The sync path now delegates to the shared validateVariableType, so a // POINTER TO INT variable on a ULINT-typed block pin (e.g. ADR output) // must NOT be flagged as wrong. - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myPtr', 'POINTER TO INT', 'user-data-type') const node = makeNode('n1', 'block', { name: 'myPtr' } as Partial, { variant: { name: 'ULINT' }, @@ -99,13 +99,13 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types are considered compatible and wrongVariable is not set, so no update. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when node has no variable', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'contact') const ladderFlows = [ { @@ -114,12 +114,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([makeVariable('x')], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([makeVariable('x')], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when variable is not found in newVars', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'contact', { name: 'missing' } as Partial) const ladderFlows = [ { @@ -128,12 +128,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([makeVariable('other')], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([makeVariable('other')], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('filters flows by editorName when provided', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -146,13 +146,66 @@ describe('syncNodesWithVariables', () => { { name: 'editor2', rungs: [{ id: 'r2', nodes: [node], edges: [] }] }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode, 'editor1') - expect(updateNode).toHaveBeenCalledTimes(1) - expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'editor1' })) + syncNodesWithVariables([variable], ladderFlows, updateNodes, 'editor1') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ editorName: 'editor1' })]) + }) + + it('scopes the sweep to a single rung when rungId is provided', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleContact = (id: string) => + makeNode(id, 'contact', { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }) + + const ladderFlows = [ + { + name: 'editor1', + rungs: [ + { id: 'r1', nodes: [staleContact('n1')], edges: [] }, + { id: 'r2', nodes: [staleContact('n2')], edges: [] }, + ], + }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNodes, 'editor1', 'r2') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ rungId: 'r2', nodeId: 'n2' })]) + }) + + it('batches corrections across rungs into a single call', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleContact = (id: string) => + makeNode(id, 'contact', { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }) + + const ladderFlows = [ + { + name: 'editor1', + rungs: [ + { id: 'r1', nodes: [staleContact('n1')], edges: [] }, + { id: 'r2', nodes: [staleContact('n2')], edges: [] }, + ], + }, + ] as unknown as Parameters[1] + + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([ + expect.objectContaining({ rungId: 'r1', nodeId: 'n1' }), + expect.objectContaining({ rungId: 'r2', nodeId: 'n2' }), + ]) }) it("skips variable-pin nodes whose pin type cannot be resolved (never judges against '')", () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL') const node = makeNode('n1', 'variable', { name: 'myVar' } as Partial, { wrongVariable: true }) @@ -163,10 +216,10 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // No data.block.variableType -> expected type unknown -> don't judge. // (The old behavior compared against '' and flagged every linked pin.) - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) const pinExtra = (pinType: string) => ({ @@ -179,7 +232,7 @@ describe('syncNodesWithVariables', () => { }) it('accepts a variable-pin node whose variable matches the pin type', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'BOOL') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) @@ -187,12 +240,12 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('flags a variable-pin node whose variable mismatches the pin type', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'INT') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, pinExtra('BOOL')) @@ -200,8 +253,8 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ @@ -210,11 +263,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('clears a stale wrongVariable flag on a variable-pin node once the pin type matches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('reset_in', 'BOOL') const node = makeNode('n1', 'variable', { name: 'reset_in' } as Partial, { ...pinExtra('BOOL'), @@ -225,8 +278,8 @@ describe('syncNodesWithVariables', () => { typeof syncNodesWithVariables >[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ @@ -235,11 +288,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('does not update a block node when nothing changed', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'contact', { name: 'myVar', @@ -254,12 +307,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('skips a variable node when it has no expected type to compare', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'variable', { name: 'myVar', @@ -274,14 +327,14 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Unresolvable expected type -> the node is not judged (the old behavior // compared against '' and flagged every linked pin as broken). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('skips a block node when its variant has no name (no expected type)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode('n1', 'block', { name: 'myVar', @@ -296,12 +349,12 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('clears wrongVariable when types now match (line 77)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode( 'n1', @@ -321,11 +374,11 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) + syncNodesWithVariables([variable], ladderFlows, updateNodes) // Types match (BOOL === BOOL for contact), but wrongVariable was true, // so it should be cleared. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'editor1', rungId: 'r1', @@ -337,11 +390,11 @@ describe('syncNodesWithVariables', () => { }), }), }), - ) + ]) }) it('marks a block node with matching variant as wrongVariable when type mismatches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode( 'n1', @@ -361,20 +414,20 @@ describe('syncNodesWithVariables', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariables([variable], ladderFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariables([variable], ladderFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ wrongVariable: true }), }), }), - ) + ]) }) }) describe('syncNodesWithVariablesFBD', () => { it('skips a variable node when its type cannot be resolved', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') const node = makeNode('n1', 'input-variable', { name: 'myVar', @@ -389,36 +442,36 @@ describe('syncNodesWithVariablesFBD', () => { }, ] as unknown as Parameters[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Unresolvable expected type -> not judged (the old behavior compared // against '' and falsely flagged linked FBD variable nodes as broken). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when node has no variable', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'block') const fbdFlows = [{ name: 'fbd1', rung: { nodes: [node], edges: [] } }] as unknown as Parameters< typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([makeVariable('x')], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([makeVariable('x')], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('does not update when variable not found', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const node = makeNode('n1', 'input-variable', { name: 'missing' } as Partial) const fbdFlows = [{ name: 'fbd1', rung: { nodes: [node], edges: [] } }] as unknown as Parameters< typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([makeVariable('other')], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([makeVariable('other')], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) it('marks a block node as wrongVariable when type mismatches', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT') const node = makeNode( 'n1', @@ -435,18 +488,18 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - expect(updateNode).toHaveBeenCalledWith( + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ node: expect.objectContaining({ data: expect.objectContaining({ wrongVariable: true }), }), }), - ) + ]) }) it('does not update a block node when only variable id changes but types still match', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const oldVariable = makeVariable('myVar', 'BOOL', 'base-type', '1') const newVariable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node = makeNode('n1', 'block', oldVariable, { variant: { name: 'BOOL' } }) @@ -455,13 +508,13 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([newVariable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([newVariable], fbdFlows, updateNodes) // Types match and wrongVariable is not set, so no update is needed. - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('filters flows by editorName', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'INT', 'base-type', '2') // Block with a resolvable variant type that mismatches -> triggers an update. const node = makeNode( @@ -480,13 +533,40 @@ describe('syncNodesWithVariablesFBD', () => { { name: 'fbd2', rung: { nodes: [node], edges: [] } }, ] as unknown as Parameters[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode, 'fbd1') - expect(updateNode).toHaveBeenCalledTimes(1) - expect(updateNode).toHaveBeenCalledWith(expect.objectContaining({ editorName: 'fbd1' })) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes, 'fbd1') + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([expect.objectContaining({ editorName: 'fbd1' })]) + }) + + it('batches corrections across nodes into a single call', () => { + const updateNodes = vi.fn() + const variable = makeVariable('myVar', 'INT') + const staleBlock = (id: string) => + makeNode( + id, + 'block', + { + name: 'myVar', + id: '1', + type: { definition: 'base-type', value: 'BOOL' } as PLCVariable['type'], + }, + { variant: { name: 'BOOL' } }, + ) + + const fbdFlows = [ + { name: 'fbd1', rung: { nodes: [staleBlock('n1'), staleBlock('n2')], edges: [] } }, + ] as unknown as Parameters[1] + + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).toHaveBeenCalledTimes(1) + expect(updateNodes).toHaveBeenCalledWith([ + expect.objectContaining({ nodeId: 'n1' }), + expect.objectContaining({ nodeId: 'n2' }), + ]) }) it('marks an input-variable node as wrong when it has no expected type to compare', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node = makeNode('n1', 'input-variable', { name: 'myVar', @@ -498,13 +578,13 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Unresolvable expected type -> not judged (no false "broken" flags). - expect(updateNode).not.toHaveBeenCalled() + expect(updateNodes).not.toHaveBeenCalled() }) it('clears wrongVariable on FBD node when types now match (line 136)', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '2') const node: Node = { id: 'n1', @@ -525,11 +605,11 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) // Types match (BOOL === BOOL for block with variant name BOOL), // but wrongVariable was true, so it should be cleared. - expect(updateNode).toHaveBeenCalledWith( + expect(updateNodes).toHaveBeenCalledWith([ expect.objectContaining({ editorName: 'fbd1', nodeId: 'n1', @@ -540,11 +620,11 @@ describe('syncNodesWithVariablesFBD', () => { }), }), }), - ) + ]) }) it('does not update a non-variable block node when nothing changed', () => { - const updateNode = vi.fn() + const updateNodes = vi.fn() const variable = makeVariable('myVar', 'BOOL', 'base-type', '1') const node: Node = { id: 'n1', @@ -560,7 +640,7 @@ describe('syncNodesWithVariablesFBD', () => { typeof syncNodesWithVariablesFBD >[1] - syncNodesWithVariablesFBD([variable], fbdFlows, updateNode) - expect(updateNode).not.toHaveBeenCalled() + syncNodesWithVariablesFBD([variable], fbdFlows, updateNodes) + expect(updateNodes).not.toHaveBeenCalled() }) }) diff --git a/src/frontend/utils/graphical/sync-nodes-with-variables.ts b/src/frontend/utils/graphical/sync-nodes-with-variables.ts index 0d7496197..5a2b992a7 100644 --- a/src/frontend/utils/graphical/sync-nodes-with-variables.ts +++ b/src/frontend/utils/graphical/sync-nodes-with-variables.ts @@ -3,14 +3,21 @@ import { Node } from '@xyflow/react' import type { PLCVariable } from '../../../middleware/shared/ports/types' import { validateVariableType } from '../PLC/validate-variable-type' -type UpdateLadderNodeFn = (params: { +export type LadderNodeUpdate = { editorName: string rungId: string nodeId: string node: import('@xyflow/react').Node -}) => void +} + +export type FBDNodeUpdate = { editorName: string; nodeId: string; node: import('@xyflow/react').Node } + +// Batched: called at most once per sync pass with every corrected node, so +// the store applies the whole pass as a single commit instead of one +// produce() write per mismatched node. +type UpdateLadderNodesFn = (updates: LadderNodeUpdate[]) => void -type UpdateFBDNodeFn = (params: { editorName: string; nodeId: string; node: import('@xyflow/react').Node }) => void +type UpdateFBDNodesFn = (updates: FBDNodeUpdate[]) => void type LadderRung = { id: string; nodes: import('@xyflow/react').Node[] } type LadderFlow = { name: string; rungs: LadderRung[] } @@ -49,128 +56,100 @@ const getBlockExpectedType = (node: Node): string => { const sameType = (firstType: string, secondType: string) => validateVariableType(firstType.toString().trim(), secondType.toString().trim()).isValid +const getNodeCorrection = (node: Node, newVars: PLCVariable[]): { data: Node['data'] } | undefined => { + const nodeVar = (node.data as { variable?: PLCVariable }).variable + + if (!nodeVar) return undefined + + const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) + + if (!target) return undefined + + const expectedType = getBlockExpectedType(node) + + // Unknown expectation — don't judge. sameType(x, '') is always false, + // so flagging here would mark perfectly valid links as broken. + if (!expectedType) return undefined + + const isTheSameType = sameType(target.type.value, expectedType) + + if (!isTheSameType) { + return { + data: { + ...node.data, + variable: { ...target, id: `broken-${node.id}` }, + wrongVariable: true, + }, + } + } + + if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { + return { + data: { + ...node.data, + variable: target, + wrongVariable: false, + }, + } + } + + return undefined +} + +// `rungId` scopes an element-level edit (add/remove/drag-stop) to the rung it +// touched. Variable-table edits (rename/retype/delete) must NOT pass it — the +// full sweep is what auto-corrects stale bindings in the other rungs. export const syncNodesWithVariables = ( newVars: PLCVariable[], ladderFlows: LadderFlow[], - updateNode: UpdateLadderNodeFn, + updateNodes: UpdateLadderNodesFn, editorName?: string, + rungId?: string, ) => { const flowsToSync = editorName ? ladderFlows.filter((flow) => flow.name === editorName) : ladderFlows + const updates: LadderNodeUpdate[] = [] - flowsToSync.forEach((flow) => - flow.rungs.forEach((rung) => + flowsToSync.forEach((flow) => { + const rungsToSync = rungId ? flow.rungs.filter((rung) => rung.id === rungId) : flow.rungs + rungsToSync.forEach((rung) => rung.nodes.forEach((node) => { - const nodeVar = (node.data as { variable?: PLCVariable }).variable - - if (!nodeVar) return - - const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) - - if (!target) return - - const expectedType = getBlockExpectedType(node) - - // Unknown expectation — don't judge. sameType(x, '') is always false, - // so flagging here would mark perfectly valid links as broken. - if (!expectedType) return - - const isTheSameType = sameType(target.type.value, expectedType) - - if (!isTheSameType) { - updateNode({ - editorName: flow.name, - rungId: rung.id, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: { ...target, id: `broken-${node.id}` }, - wrongVariable: true, - }, - }, - }) - - return - } - - if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { - updateNode({ - editorName: flow.name, - rungId: rung.id, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: target, - wrongVariable: false, - }, - }, - }) - } + const correction = getNodeCorrection(node, newVars) + if (!correction) return + + updates.push({ + editorName: flow.name, + rungId: rung.id, + nodeId: node.id, + node: { ...node, ...correction }, + }) }), - ), - ) + ) + }) + + if (updates.length > 0) updateNodes(updates) } export const syncNodesWithVariablesFBD = ( newVars: PLCVariable[], fbdFlows: FBDFlow[], - updateNode: UpdateFBDNodeFn, + updateNodes: UpdateFBDNodesFn, editorName?: string, ) => { const flowsToSync = editorName ? fbdFlows.filter((flow) => flow.name === editorName) : fbdFlows + const updates: FBDNodeUpdate[] = [] flowsToSync.forEach((flow) => flow.rung.nodes.forEach((node) => { - const nodeVar = (node.data as { variable?: PLCVariable }).variable - - if (!nodeVar) return - - const target = newVars.find((v) => v.name.toLowerCase() === nodeVar.name.toLowerCase()) - - if (!target) return - - const expectedType = getBlockExpectedType(node) - - // Unknown expectation — don't judge. sameType(x, '') is always false, - // so flagging here would mark perfectly valid links as broken. - if (!expectedType) return - - const isTheSameType = sameType(target.type.value, expectedType) - - if (!isTheSameType) { - updateNode({ - editorName: flow.name, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: { ...target, id: `broken-${node.id}` }, - wrongVariable: true, - }, - }, - }) - - return - } - - if ((node.data as { wrongVariable?: PLCVariable }).wrongVariable) { - updateNode({ - editorName: flow.name, - nodeId: node.id, - node: { - ...node, - data: { - ...node.data, - variable: target, - wrongVariable: false, - }, - }, - }) - } + const correction = getNodeCorrection(node, newVars) + if (!correction) return + + updates.push({ + editorName: flow.name, + nodeId: node.id, + node: { ...node, ...correction }, + }) }), ) + + if (updates.length > 0) updateNodes(updates) } From 01b84a3c3b83013867dc4f14816533841ea72d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20GS=20Pereira?= Date: Tue, 21 Jul 2026 17:45:37 -0300 Subject: [PATCH 2/2] test: strengthen FBD updateNodes dirty-state assertions + cover use-content-stable Addresses CodeRabbit review: reset the FBD flow's updated flag before each updateNodes batch so the assertions actually prove the action's behavior, and add unit tests locking in the useContentStable/mapsEqual identity-stability contract. Mirror of openplc-web fix/dope-491-hot-path-scans Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017RGT8nUsyY26HXFSuTBFLz --- .../__tests__/use-content-stable.test.ts | 98 +++++++++++++++++++ .../store/__tests__/fbd-slice.test.ts | 3 + 2 files changed, 101 insertions(+) create mode 100644 src/frontend/hooks/__tests__/use-content-stable.test.ts diff --git a/src/frontend/hooks/__tests__/use-content-stable.test.ts b/src/frontend/hooks/__tests__/use-content-stable.test.ts new file mode 100644 index 000000000..0eb629a0a --- /dev/null +++ b/src/frontend/hooks/__tests__/use-content-stable.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '@testing-library/react' + +import { mapsEqual, useContentStable } from '../use-content-stable' + +describe('mapsEqual', () => { + it('returns true for the same reference', () => { + const map = new Map([['a', 1]]) + expect(mapsEqual(map, map)).toBe(true) + }) + + it('returns true for different instances with equal content', () => { + expect( + mapsEqual( + new Map([ + ['a', 1], + ['b', 2], + ]), + new Map([ + ['b', 2], + ['a', 1], + ]), + ), + ).toBe(true) + }) + + it('returns false when sizes differ', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map())).toBe(false) + }) + + it('returns false when a value differs', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map([['a', 2]]))).toBe(false) + }) + + it('returns false when a key is missing', () => { + expect(mapsEqual(new Map([['a', 1]]), new Map([['b', 1]]))).toBe(false) + }) + + it('distinguishes a missing key from an undefined value', () => { + expect(mapsEqual(new Map([['a', undefined]]), new Map([['b', undefined]]))).toBe(false) + }) +}) + +describe('useContentStable', () => { + type States = Map | null + const statesEqual = (a: States, b: States) => a !== null && b !== null && mapsEqual(a, b) + + const render = (initial: States) => + renderHook(({ value }: { value: States }) => useContentStable(value, statesEqual), { + initialProps: { value: initial }, + }) + + it('keeps the previous reference when content is equal', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: new Map([['edge-1', true]]) }) + + expect(result.current).toBe(first) + }) + + it('swaps to the new reference when content differs', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + const changed = new Map([['edge-1', false]]) + rerender({ value: changed }) + + expect(result.current).toBe(changed) + }) + + it('transitions from a value to null (equality returns false for null)', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: null }) + + expect(result.current).toBeNull() + }) + + it('transitions from null to a value', () => { + const { result, rerender } = render(null) + + const next = new Map([['edge-1', true]]) + rerender({ value: next }) + + expect(result.current).toBe(next) + }) + + it('keeps the stable reference across multiple equal-content updates', () => { + const first = new Map([['edge-1', true]]) + const { result, rerender } = render(first) + + rerender({ value: new Map([['edge-1', true]]) }) + rerender({ value: new Map([['edge-1', true]]) }) + + expect(result.current).toBe(first) + }) +}) diff --git a/src/frontend/store/__tests__/fbd-slice.test.ts b/src/frontend/store/__tests__/fbd-slice.test.ts index 64ba093d5..61d4596e0 100644 --- a/src/frontend/store/__tests__/fbd-slice.test.ts +++ b/src/frontend/store/__tests__/fbd-slice.test.ts @@ -273,6 +273,7 @@ describe('createFBDFlowSlice', () => { editorName: 'editor-1', nodes: [makeNode({ id: 'n1', data: { label: 'old-1' } }), makeNode({ id: 'n2', data: { label: 'old-2' } })], }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) store.getState().fbdFlowActions.updateNodes([ { editorName: 'editor-1', nodeId: 'n1', node: makeNode({ id: 'n1', data: { label: 'new-1' } }) }, @@ -291,6 +292,7 @@ describe('createFBDFlowSlice', () => { editorName: 'editor-1', nodes: [makeNode({ id: 'n1', data: { label: 'old' } })], }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'editor-1', updated: false }) store.getState().fbdFlowActions.updateNodes([ { editorName: 'missing-editor', nodeId: 'n1', node: makeNode({ id: 'n1' }) }, @@ -298,6 +300,7 @@ describe('createFBDFlowSlice', () => { ]) expect(store.getState().fbdFlows[0].rung.nodes.find((n) => n.id === 'n1')?.data.label).toBe('old') + expect(store.getState().fbdFlows[0].updated).toBe(false) }) // -------------------------------------------------------------------------