Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { RungLadderState } from '@root/frontend/store/slices'
import type { Edge, Node } from '@xyflow/react'
import { Position } from '@xyflow/react'
Expand Down Expand Up @@ -1326,3 +1326,37 @@

return nodeIds
}

/**
* Rebuild the handle-branch index from a rung's graph.
*
* `handleBranches` is runtime-only state — it is NOT persisted in the .ld file,
* so a project loaded from disk that contains handle branches (contacts/coils
* wired to a block's secondary input/output handles, e.g. CTUD CD/QD) comes
* back with an empty index. Branch-aware operations (deletion cleanup,
* `getBranch`, nodeId reconciliation) then can't find those branches and
* corrupt the diagram on the first edit. We reconstruct the index from the
* `branchContext` stamped on each branch node plus the block's handle
* directions, deriving the ordered nodeIds via `reconcileBranchNodeIds`.
*/
export function deriveHandleBranches(rung: RungLadderState): HandleBranch[] {
const seen = new Map<string, { blockId: string; handleId: string }>()
for (const node of rung.nodes) {
const ctx = (node.data as BasicNodeData).branchContext
if (ctx?.blockId && ctx?.handleId) {
seen.set(`${ctx.blockId}::${ctx.handleId}`, { blockId: ctx.blockId, handleId: ctx.handleId })
}
}

const branches: HandleBranch[] = []
for (const { blockId, handleId } of seen.values()) {
const block = rung.nodes.find((n) => n.id === blockId)
if (!block) continue
const data = block.data as BasicNodeData
const isOutput = (data.outputHandles ?? []).some((h) => h.id === handleId)
const direction: 'input' | 'output' = isOutput ? 'output' : 'input'
const nodeIds = reconcileBranchNodeIds(rung, { blockId, handleId, direction, nodeIds: [] })
if (nodeIds.length > 0) branches.push({ blockId, handleId, direction, nodeIds })
}
return branches
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { RungLadderState } from '@root/frontend/store/slices'
import { newGraphicalEditorNodeID } from '@root/frontend/utils/new-graphical-editor-node-id'
import type { Edge, Node } from '@xyflow/react'
Expand Down Expand Up @@ -57,10 +57,7 @@
/**
* Get the previous node
*/
let previousNode = getPreviousElement(
{ ...rung, nodes: newNodes, edges: newEdges },
newNodes.findIndex((n) => n.id === newElement.id),
)
let previousNode = getPreviousElement({ ...rung, nodes: newNodes, edges: newEdges }, newElement.id)

/**
* If the related node is a parallel, check if it is an open or close parallel
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { Node } from '@xyflow/react'

import type { RungLadderState } from '@root/frontend/store/slices'

import { getPreviousElement } from '../index'

/**
* Minimal node/rung factories — getPreviousElement only reads node.type,
* node.id and node.data.branchContext.
*/
const node = (
id: string,
type: string,
branchContext?: { blockId: string; handleId: string; direction: 'input' | 'output' },
): Node =>
({
id,
type,
position: { x: 0, y: 0 },
data: branchContext ? { branchContext } : {},
}) as unknown as Node

const rung = (nodes: Node[]): RungLadderState => ({ nodes }) as unknown as RungLadderState

describe('getPreviousElement', () => {
// Regression: adding a coil to a counter block's primary output corrupted a
// handle branch (e.g. a contact on the R input). Branch elements are
// interleaved in the node array between the block and the right rail, so a
// freshly-inserted main-line coil's placeholder sits AFTER the branch contact.
// The old index-based walk picked the branch contact as the serial
// predecessor and spliced the coil into the branch edge (dropping the coil and
// breaking the branch). The predecessor must be the block, skipping the branch.
it('returns the main-line predecessor, skipping handle-branch elements', () => {
const r = rung([
node('left-rail', 'powerRail'),
node('cu-contact', 'contact'),
node('block', 'block'),
node('r-contact', 'contact', { blockId: 'block', handleId: 'R', direction: 'input' }),
node('new-coil', 'coil'),
node('right-rail', 'powerRail'),
])

const previous = getPreviousElement(r, 'new-coil')

expect(previous.id).toBe('block')
expect(previous.id).not.toBe('r-contact')
})

it('skips placeholders and variables when resolving the predecessor', () => {
const r = rung([
node('left-rail', 'powerRail'),
node('contact-1', 'contact'),
node('a-variable', 'variable'),
node('a-placeholder', 'placeholder'),
node('new-contact', 'contact'),
node('right-rail', 'powerRail'),
])

expect(getPreviousElement(r, 'new-contact').id).toBe('contact-1')
})

it('skips multiple consecutive branch elements before the inserted node', () => {
const r = rung([
node('left-rail', 'powerRail'),
node('block', 'block'),
node('cd-contact', 'contact', { blockId: 'block', handleId: 'CD', direction: 'input' }),
node('r-contact', 'contact', { blockId: 'block', handleId: 'R', direction: 'input' }),
node('new-coil', 'coil'),
node('right-rail', 'powerRail'),
])

expect(getPreviousElement(r, 'new-coil').id).toBe('block')
})
})
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { RungLadderState } from '@root/frontend/store/slices'
import type { Edge, Node } from '@xyflow/react'

Expand Down Expand Up @@ -65,19 +65,36 @@
}

/**
* Get the previous node when adding a new element
* It works when removing the placeholder and variables elements
* Get the previous main-line element when adding a new serial element.
*
* Walks the rung's node list skipping placeholders, variables and branch
* elements, then returns the element immediately before the just-inserted one.
*
* Branch elements (contacts/coils wired to a block's secondary handles, e.g. a
* contact on a counter's R input) are interleaved in the node array between the
* block and the right rail, but they are NOT part of the serial spine. A plain
* index walk that included them would treat such a branch element as the serial
* predecessor of a main-line element — so a coil added to a block's primary
* output (whose placeholder sits after the branch element in the array) would be
* wired into the branch edge instead, dropping the coil and breaking the branch.
* Skipping branchContext nodes here mirrors getPreviousElementsByEdge. Keying off
* the element id (rather than an externally-computed index) keeps the lookup
* consistent with this filtered spine.
*
* @param rung: RungLadderState
* @param nodeIndex: number
* @param newElementId: string
*
* @returns Node
*/
export const getPreviousElement = (rung: RungLadderState, nodeIndex: number): Node => {
const nodesWithNoPlaceholderAndVariables = rung.nodes.filter(
(n) => n.type !== 'placeholder' && n.type !== 'parallelPlaceholder' && n.type !== 'variable',
export const getPreviousElement = (rung: RungLadderState, newElementId: string): Node => {
const serialSpine = rung.nodes.filter(
(n) =>
n.type !== 'placeholder' &&
n.type !== 'parallelPlaceholder' &&
n.type !== 'variable' &&
!(n.data as BasicNodeData).branchContext,
)
return nodesWithNoPlaceholderAndVariables[nodeIndex - 1]
return serialSpine[serialSpine.findIndex((n) => n.id === newElementId) - 1]
Comment on lines +89 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C4 'getPreviousElement\s*\(' src/frontend

Repository: Autonomy-Logic/openplc-editor

Length of output: 5467


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts
printf '\n---\n'
sed -n '1,180p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts
printf '\n---\n'
sed -n '1,180p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/__tests__/get-previous-element.test.ts
EOF

Repository: Autonomy-Logic/openplc-editor

Length of output: 12013


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,140p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts
printf '\n---\n'
sed -n '1,180p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts
printf '\n---\n'
sed -n '1,180p' src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/__tests__/get-previous-element.test.ts

Repository: Autonomy-Logic/openplc-editor

Length of output: 11972


Guard the predecessor lookup before dereferencing

getPreviousElement() can return undefined when the id is missing or is the first serial node, but it is typed as Node. appendSerialConnection() then calls previousNode.id without a guard, which can throw. Return Node | undefined and handle the fallback before connectNodes().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts`
around lines 89 - 97, getPreviousElement currently assumes a predecessor always
exists, but it can return undefined when newElementId is missing or is the first
serial node, while appendSerialConnection dereferences previousNode.id
unconditionally. Update getPreviousElement in the ladder utils index to return
Node | undefined, then add a guard in appendSerialConnection before calling
connectNodes so the fallback path handles the missing predecessor safely.

}

/**
Expand Down
14 changes: 13 additions & 1 deletion src/frontend/store/slices/ladder/slice.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { addEdge, applyEdgeChanges, applyNodeChanges } from '@xyflow/react'
import { produce } from 'immer'
import { StateCreator } from 'zustand'
Expand All @@ -9,6 +9,7 @@
} from '../../../components/_atoms/graphical-editor/ladder/node-builders'
import type { LadderBlockConnectedVariables } from '../../../components/_atoms/graphical-editor/ladder/utils/types'
import { removeElements } from '../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements'
import { deriveHandleBranches } from '../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch'
import { LadderFlowSlice, LadderFlowState } from './types'
import { duplicateLadderRung } from './utils'

Expand Down Expand Up @@ -56,9 +57,20 @@
}))
: flow.rungs.map((rung) => ({ ...rung, selectedNodes: [] }))

// handleBranches (the index of contacts/coils wired to a block's
// secondary handles, e.g. CTUD CD/QD) is runtime-only state — it is
// NOT persisted in the .ld. Without rebuilding it on load, a project
// containing handle branches comes back with an empty index and the
// first branch-aware edit (e.g. deleting a coil on a block output)
// corrupts the diagram. Reconstruct it from the graph here.
const rungsWithBranches = rungs.map((rung) => ({
...rung,
handleBranches: deriveHandleBranches(rung),
}))

// Reset updated to false on load — the flow is being loaded from a saved project.
// Only mark as updated if legacy data was migrated so the next save writes the new format.
const newFlow = { ...flow, rungs, updated: needsMigration }
const newFlow = { ...flow, rungs: rungsWithBranches, updated: needsMigration }

if (flowIndex === -1) {
ladderFlows.push(newFlow)
Expand Down
22 changes: 21 additions & 1 deletion src/frontend/store/slices/ladder/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Edge, Node } from '@xyflow/react'

import type { PLCVariable } from '../../../../../middleware/shared/ports/types'
Expand All @@ -15,6 +15,7 @@
PowerRailNode,
VariableNode,
} from '../../../../components/_atoms/graphical-editor/ladder/utils/types'
import { updateDiagramElementsPosition } from '../../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram'
import { generateNumericUUID } from '../../../../utils/generate-uuid'
import { newGraphicalEditorNodeID } from '../../../../utils/new-graphical-editor-node-id'
import { RungLadderState } from '../types'
Expand Down Expand Up @@ -129,6 +130,7 @@
} as ContactNode
}
case 'parallel': {
const parallelData = node.data as BasicNodeData
return {
...node,
id: nodeMaps[node.id].id,
Expand All @@ -141,6 +143,16 @@
parallelOpenReference: (node as ParallelNode).data.parallelOpenReference
? nodeMaps[(node as ParallelNode).data.parallelOpenReference ?? ''].id
: undefined,
// Branch parallel nodes (a parallel inside a handle branch) carry a
// branchContext whose blockId must be remapped to the duplicated
// block — same as coils/contacts. Without this the copy's parallel
// nodes point at the original block id, breaking branch operations.
...(parallelData.branchContext && {
branchContext: {
...parallelData.branchContext,
blockId: nodeMaps[parallelData.branchContext.blockId]?.id ?? parallelData.branchContext.blockId,
},
}),
},
} as ParallelNode
}
Expand Down Expand Up @@ -208,7 +220,15 @@
}),
}

return newRung
// Blocks/coils/contacts are rebuilt at their DEFAULT dimensions by
// nodesBuilder, so a block that was branch-expanded in the source rung comes
// back compact while its branch elements keep the expanded positions —
// leaving the duplicated diagram visually broken. Re-run the (branch-aware)
// layout solver so the block re-expands and every element is repositioned
// against the actual node set.
const laidOut = updateDiagramElementsPosition(newRung, newRung.defaultBounds as [number, number])

return { ...newRung, nodes: laidOut.nodes, edges: laidOut.edges }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { BlockNode, BlockVariant } from '@root/frontend/components/_atoms/graphical-editor/ladder/block'
import { CoilNode } from '@root/frontend/components/_atoms/graphical-editor/ladder/coil'
import { ContactNode } from '@root/frontend/components/_atoms/graphical-editor/ladder/contact'
Expand Down Expand Up @@ -90,6 +90,20 @@
) => {
const { nodes: rungNodes, edges: rungEdges } = rung

// Resolve the formal parameter (block output pin) for a source node that
// feeds into a parallel chain. A block's `outputConnector` only records its
// PRIMARY output (e.g. QU on a CTUD_DINT), so reading it directly maps EVERY
// branch back to that one pin — which is why coils wired to a secondary
// output (QD) were serialized as connected to QU. Instead, use the handle of
// the edge that actually leaves the node into the parallel chain (QU vs QD).
// Falls back to the default output connector for single-output elements;
// 'OUT' (the unnamed function return) maps to an empty formal parameter.
const formalParameterFromParallel = (srcNode: Node<BasicNodeData>, parallelChain: ParallelNode[]): string => {
const edge = rungEdges.find((e) => e.source === srcNode.id && parallelChain.some((p) => p.id === e.target))
const handle = edge?.sourceHandle ?? srcNode.data.outputConnector?.id ?? ''
return handle === 'OUT' ? '' : handle
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const connectedEdges = rungEdges.filter(
(edge) => edge.target === node.id && (targetHandle === undefined || edge.targetHandle === targetHandle),
)
Expand Down Expand Up @@ -148,7 +162,7 @@
if (lastParallelSerialEdge && lastParallelSerialEdge.target === node.id) {
return nodes.map((node, index) => ({
'@refLocalId': node.data.numericId,
'@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '',
'@formalParameter': formalParameterFromParallel(node, parallels),
position:
index === 0
? [
Expand Down Expand Up @@ -220,7 +234,7 @@
return nodes.map((node) => {
return {
'@refLocalId': node.data.numericId,
'@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '',
'@formalParameter': formalParameterFromParallel(node, parallels),
position: [
// Final edge destination
{
Expand Down Expand Up @@ -255,7 +269,7 @@
const closeConnections = nodes.map((node, index) => {
return {
'@refLocalId': node.data.numericId,
'@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '',
'@formalParameter': formalParameterFromParallel(node, parallels),
position:
index === 0
? [
Expand Down
Loading