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,7 +1,7 @@
import { useEffect, useMemo } from 'react'

import { useOpenPLCStore } from '../../../../../../store'
import { zodFBDFlowSchema } from '../../../../../../store/slices/fbd'
import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback'
import { BlockNodeData } from '../../../../../_atoms/graphical-editor/fbd/block'
import { BlockVariant } from '../../../../../_atoms/graphical-editor/types/block'
import { FBDBody } from '../../../../../_molecules/graphical-editor/fbd'
Expand All @@ -19,11 +19,6 @@ export default function FbdEditor() {
const fbdFlows = useOpenPLCStore((state) => state.fbdFlows)
const pous = useOpenPLCStore((state) => state.project.data.pous)
const userLibraries = useOpenPLCStore((state) => state.libraries.user)
const fbdFlowActions = useOpenPLCStore((state) => state.fbdFlowActions)
const updatePou = useOpenPLCStore((state) => state.projectActions.updatePou)
const handleFileAndWorkspaceSavedState = useOpenPLCStore(
(state) => state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState,
)
const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible)

const flow = fbdFlows.find((flow) => flow.name === pouName)
Expand Down Expand Up @@ -84,35 +79,15 @@ export default function FbdEditor() {
}, [flow?.rung.nodes, userLibraries, pous])

/**
* Update the flow state to project JSON.
*
* Validate the flow with Zod but persist the raw object (minus the
* transient `updated` flag). Using the parsed result would silently strip
* every field not declared in `zodFBDFlowSchema` and reorder keys to
* schema order, which makes `serializeGraphicalPouToString` produce
* byte-drift vs. the loaded disk copy — surfacing as phantom "Modified"
* entries in Source Control for POUs the user never edited.
* Queue the flow → project JSON write-back. The scheduler debounces it
* (edits inside the window coalesce), persists the raw flow object, and
* clears the `updated` flag; save paths flush it so a save landing inside
* the window still serializes the fresh body. Validation and the DOPE-477
* raw-object policy live in store/slices/shared/flow-writeback.ts.
*/
useEffect(() => {
if (!flowUpdated || !flow) return

const flowSchema = zodFBDFlowSchema.safeParse(flow)
if (!flowSchema.success) return

const { updated: _updated, ...flowBody } = flow
updatePou({
name: pouName,
content: {
language: 'fbd',
value: structuredClone(flowBody),
},
})

fbdFlowActions.setFlowUpdated({ editorName: pouName, updated: false })

if (!isDebuggerVisible) {
handleFileAndWorkspaceSavedState(pouName)
}
if (!flowUpdated) return
scheduleFlowWriteBack(useOpenPLCStore.getState, pouName, 'fbd')
}, [flowUpdated])

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ import {
import { restrictToParentElement } from '@dnd-kit/modifiers'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import * as Portal from '@radix-ui/react-portal'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { v4 as uuidv4 } from 'uuid'

import { usePouSnapshot } from '../../../../../../hooks/use-pou-snapshot'
import { ladderSelectors } from '../../../../../../hooks/use-store-selectors'
import { openPLCStoreBase, useOpenPLCStore } from '../../../../../../store'
import { RungLadderState, zodLadderFlowSchema } from '../../../../../../store/slices/ladder'
import type { PouHistorySnapshot } from '../../../../../../store/slices/shared/types'
import { useOpenPLCStore } from '../../../../../../store'
import { RungLadderState } from '../../../../../../store/slices/ladder'
import { scheduleFlowWriteBack } from '../../../../../../store/slices/shared/flow-writeback'
import { cn } from '../../../../../../utils/cn'
import { BlockNode, BlockNodeData } from '../../../../../_atoms/graphical-editor/ladder/block'
import { CoilNode } from '../../../../../_atoms/graphical-editor/ladder/coil'
Expand Down Expand Up @@ -51,27 +52,11 @@ export default function LadderEditor() {
const contactElementModal = useOpenPLCStore((state) => state.modals['contact-ladder-element'])
const coilElementModal = useOpenPLCStore((state) => state.modals['coil-ladder-element'])
const pous = useOpenPLCStore((state) => state.project.data.pous)
const updatePou = useOpenPLCStore((state) => state.projectActions.updatePou)
const closeModal = useOpenPLCStore((state) => state.modalActions.closeModal)
const handleFileAndWorkspaceSavedState = useOpenPLCStore(
(state) => state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState,
)
const pushToHistory = useOpenPLCStore((state) => state.snapshotActions.pushToHistory)
const userLibraries = useOpenPLCStore((state) => state.libraries.user)
const isDebuggerVisible = useOpenPLCStore((state) => state.workspace.isDebuggerVisible)

const captureSnapshot = useCallback(
(pouName: string): PouHistorySnapshot | null => {
const pou = pous.find((p) => p.name === pouName)
if (!pou) return null
return {
variables: pou.interface?.variables ?? [],
body: pou.body.value,
globalVariables: openPLCStoreBase.getState().project.data.configurations.resource.globalVariables,
}
},
[pous],
)
const { captureAndPush } = usePouSnapshot()

const updateModelLadder = ladderSelectors.useUpdateModelLadder()

Expand Down Expand Up @@ -150,36 +135,15 @@ export default function LadderEditor() {
}, [searchNodePosition])

/**
* Update the flow state to project JSON.
*
* Validate the flow with Zod but persist the raw object (minus the
* transient `updated` flag). Using the parsed result would silently strip
* every field not declared in `zodLadderFlowSchema` (e.g. `handleBranches`,
* `positionAbsolute`, `zIndex`) and reorder keys to schema order, which
* makes `serializeGraphicalPouToString` produce byte-drift vs. the loaded
* disk copy — surfacing as phantom "Modified" entries in Source Control
* for POUs the user never edited.
* Queue the flow → project JSON write-back. The scheduler debounces it
* (edits inside the window coalesce), persists the raw flow object, and
* clears the `updated` flag; save paths flush it so a save landing inside
* the window still serializes the fresh body. Validation and the DOPE-477
* raw-object policy live in store/slices/shared/flow-writeback.ts.
*/
useEffect(() => {
if (!flowUpdated || !flow) return

const flowSchema = zodLadderFlowSchema.safeParse(flow)
if (!flowSchema.success) return

const { updated: _updated, ...flowBody } = flow
updatePou({
name: pouName,
content: {
language: 'ld',
value: structuredClone(flowBody),
},
})

ladderFlowActions.setFlowUpdated({ editorName: pouName, updated: false })

if (!isDebuggerVisible) {
handleFileAndWorkspaceSavedState(pouName)
}
if (!flowUpdated) return
scheduleFlowWriteBack(useOpenPLCStore.getState, pouName, 'ld')
}, [flowUpdated])

const getRungPos = (rungId: UniqueIdentifier) => rungs.findIndex((rung) => rung.id === rungId)
Expand All @@ -195,8 +159,7 @@ export default function LadderEditor() {
const handleAddNewRung = () => {
if (isDebuggerVisible) return

const snapshot = captureSnapshot(pouName)
if (snapshot) pushToHistory(pouName, snapshot)
captureAndPush(pouName)

const defaultViewport: [number, number] = [300, 100]

Expand Down Expand Up @@ -255,8 +218,7 @@ export default function LadderEditor() {
auxRungs.splice(destinationIndex, 0, removed)

try {
const snapshot = captureSnapshot(pouName)
if (snapshot) pushToHistory(pouName, snapshot)
captureAndPush(pouName)
ladderFlowActions.setRungs({ editorName: pouName, rungs: auxRungs })
} catch (error) {
console.error('Failed to update rungs:', error)
Expand Down
24 changes: 16 additions & 8 deletions src/frontend/hooks/use-pou-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback } from 'react'

import { useOpenPLCStore } from '../store'
import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback'

/**
* Convenience hook wrapping snapshotActions.pushToHistory().
Expand All @@ -9,25 +10,32 @@ import { useOpenPLCStore } from '../store'
*
* State is read via getState() at capture time (not subscribed): the hook
* never re-renders its consumers and `captureAndPush` keeps a stable identity.
*
* Snapshots hold plain references into the store state — no deep clone. The
* store is immer-managed (frozen, copy-on-write), so later edits produce new
* objects and can never reach a captured snapshot. The previous JSON
* round-trips cloned the full body, both flows and all globals on every
* capture (~150-200 MB of transient garbage per edit burst on large
* projects).
*/
export function usePouSnapshot() {
const { pushToHistory, undo, redo } = useOpenPLCStore((state) => state.snapshotActions)

const captureAndPush = useCallback(
(pouName: string) => {
// A debounced graphical write-back may still be pending — flush it so
// the snapshot can't pair a stale body with a fresh flow.
flushFlowWriteBacks(useOpenPLCStore.getState, pouName)
const { project, ladderFlows, fbdFlows } = useOpenPLCStore.getState()
const pou = project.data.pous.find((p) => p.name === pouName)
if (!pou) return

const ladderFlow = ladderFlows.find((f) => f.name === pouName)
const fbdFlow = fbdFlows.find((f) => f.name === pouName)

pushToHistory(pouName, {
variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])),
body: JSON.parse(JSON.stringify(pou.body.value)),
ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined,
fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined,
globalVariables: JSON.parse(JSON.stringify(project.data.configurations.resource.globalVariables)),
variables: pou.interface?.variables ?? [],
body: pou.body.value,
ladderFlow: ladderFlows.find((f) => f.name === pouName),
fbdFlow: fbdFlows.find((f) => f.name === pouName),
globalVariables: project.data.configurations.resource.globalVariables,
})
},
[pushToHistory],
Expand Down
7 changes: 7 additions & 0 deletions src/frontend/services/save-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { ProjectPort, RawProjectFile, WriteProjectFiles } from '../../middl
import type { PLCPou } from '../../middleware/shared/ports/types'
import { openPLCStoreBase } from '../store'
import type { LadderFlowType } from '../store/slices/ladder'
import { flushFlowWriteBacks } from '../store/slices/shared/flow-writeback'
import { parseIecStringToVariables } from '../utils/generate-iec-string-to-variables'
import { generateIecVariablesToString } from '../utils/generate-iec-variables-to-string'
import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../utils/graphical/sync-nodes-with-variables'
Expand Down Expand Up @@ -321,6 +322,10 @@ export async function executeSaveProject(
projectPort: ProjectPort,
capabilities: PlatformCapabilities,
): Promise<{ success: boolean }> {
// Run any pending debounced graphical write-backs before reading state:
// a save landing inside the debounce window must serialize the fresh
// POU bodies, not the pre-edit ones.
flushFlowWriteBacks(openPLCStoreBase.getState)
const state = openPLCStoreBase.getState()
// Persist gate. Every save path — Ctrl+S, File → Save, auto-save after
// a rename/delete, the AI panel — funnels through here. When the viewer
Expand Down Expand Up @@ -491,6 +496,8 @@ export async function executeSaveFile(
projectPort: ProjectPort,
capabilities: PlatformCapabilities,
): Promise<{ success: boolean }> {
// See executeSaveProject — same pending write-back flush requirement.
flushFlowWriteBacks(openPLCStoreBase.getState)
const state = openPLCStoreBase.getState()
// See executeSaveProject for rationale — same persist gate.
if (!state.workspace.canEdit) {
Expand Down
Loading
Loading