diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx index 66b56bf8f..d8ac7033b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/FBD/index.tsx @@ -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' @@ -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) @@ -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 ( diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx index d9f3379b5..a3fc5661b 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/ladder/index.tsx @@ -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' @@ -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() @@ -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) @@ -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] @@ -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) diff --git a/src/frontend/hooks/use-pou-snapshot.ts b/src/frontend/hooks/use-pou-snapshot.ts index 2a3cc49ad..1ba92a930 100644 --- a/src/frontend/hooks/use-pou-snapshot.ts +++ b/src/frontend/hooks/use-pou-snapshot.ts @@ -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(). @@ -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], diff --git a/src/frontend/services/save-actions.ts b/src/frontend/services/save-actions.ts index 90e06f876..def00f254 100644 --- a/src/frontend/services/save-actions.ts +++ b/src/frontend/services/save-actions.ts @@ -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' @@ -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 @@ -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) { diff --git a/src/frontend/store/__tests__/flow-writeback.test.ts b/src/frontend/store/__tests__/flow-writeback.test.ts new file mode 100644 index 000000000..649595c8c --- /dev/null +++ b/src/frontend/store/__tests__/flow-writeback.test.ts @@ -0,0 +1,248 @@ +import { createStore } from 'zustand/vanilla' + +import { createAISlice } from '../slices/ai' +import { createConsoleSlice } from '../slices/console/slice' +import { createDeviceSlice } from '../slices/device/slice' +import { createEditorSlice } from '../slices/editor/slice' +import { createFBDFlowSlice } from '../slices/fbd/slice' +import { createFileSlice } from '../slices/file/slice' +import { createHistorySlice } from '../slices/history/slice' +import type { LadderFlowType } from '../slices/ladder' +import { createLadderFlowSlice } from '../slices/ladder/slice' +import { createLibrarySlice } from '../slices/library/slice' +import { createModalSlice } from '../slices/modal/slice' +import { createProjectSlice } from '../slices/project/slice' +import { createSearchSlice } from '../slices/search/slice' +import { + cancelFlowWriteBacks, + FLOW_WRITEBACK_DEBOUNCE_MS, + flushFlowWriteBacks, + scheduleFlowWriteBack, +} from '../slices/shared/flow-writeback' +import { createSharedSlice } from '../slices/shared/slice' +import type { SharedRootState } from '../slices/shared/types' +import { createTabsSlice } from '../slices/tabs/slice' +import { createVersionControlSlice } from '../slices/version-control/slice' +import { createWorkspaceSlice } from '../slices/workspace/slice' + +function makeStore() { + return createStore()((...args) => ({ + ...createProjectSlice(...args), + ...createFileSlice(...args), + ...createEditorSlice(...args), + ...createTabsSlice(...args), + ...createLibrarySlice(...args), + ...createWorkspaceSlice(...args), + ...createModalSlice(...args), + ...createSearchSlice(...args), + ...createConsoleSlice(...args), + ...createDeviceSlice(...args), + ...createFBDFlowSlice(...args), + ...createLadderFlowSlice(...args), + ...createHistorySlice(...args), + ...createVersionControlSlice(...args), + ...createAISlice(...args), + ...createSharedSlice(...args), + })) +} + +describe('flow write-back scheduler', () => { + let store: ReturnType + + const getState = () => store.getState() + + const ladderBody = (pouName: string) => + store.getState().project.data.pous.find((p) => p.name === pouName)?.body.value as LadderFlowType | undefined + + /** Create an LD program and give its flow one (valid) rung, marked updated. */ + const makeDirtyLadderPou = (name: string) => { + store.getState().pouActions.create({ type: 'program', name, language: 'ld' }) + store.getState().ladderFlowActions.startLadderRung({ + editorName: name, + rungId: `rung_${name}_1`, + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: name, updated: true }) + } + + beforeEach(() => { + vi.useFakeTimers() + store = makeStore() + }) + + afterEach(() => { + cancelFlowWriteBacks() + vi.useRealTimers() + }) + + describe('scheduleFlowWriteBack', () => { + it('marks the file dirty immediately but defers the body write-back', () => { + makeDirtyLadderPou('Main') + store.getState().fileActions.updateFile({ name: 'Main', saved: true }) + + scheduleFlowWriteBack(getState, 'Main', 'ld') + + expect(store.getState().files['Main']?.saved).toBe(false) + expect(ladderBody('Main')?.rungs).toHaveLength(0) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(1) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(false) + }) + + it('persists the flow without the transient updated flag, sharing structure with the flow slice', () => { + makeDirtyLadderPou('Main') + + scheduleFlowWriteBack(getState, 'Main', 'ld') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + const body = ladderBody('Main') + const flow = store.getState().ladderFlows.find((f) => f.name === 'Main') + expect(body && 'updated' in body).toBe(false) + // By-reference persistence: the project copy and the live flow share + // the (immutable) rung objects instead of deep-cloning them. + expect(body?.rungs[0]).toBe(flow?.rungs[0]) + }) + + it('does not mark the file dirty while the debugger is visible', () => { + makeDirtyLadderPou('Main') + store.getState().fileActions.updateFile({ name: 'Main', saved: true }) + store.getState().workspaceActions.setDebuggerVisible(true) + + scheduleFlowWriteBack(getState, 'Main', 'ld') + + expect(store.getState().files['Main']?.saved).toBe(true) + }) + + it('coalesces edits into a single pending timer (no debounce reset)', () => { + makeDirtyLadderPou('Main') + + scheduleFlowWriteBack(getState, 'Main', 'ld') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS / 2) + + // A second edit inside the window re-schedules: the first timer stands. + store.getState().ladderFlowActions.startLadderRung({ + editorName: 'Main', + rungId: 'rung_Main_2', + defaultBounds: [300, 100], + reactFlowViewport: [300, 100], + }) + scheduleFlowWriteBack(getState, 'Main', 'ld') + + // Fires DEBOUNCE_MS after the FIRST schedule and picks up both edits. + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS / 2) + expect(ladderBody('Main')?.rungs).toHaveLength(2) + }) + + it('writes back FBD flows through the same scheduler', () => { + store.getState().pouActions.create({ type: 'program', name: 'FbdMain', language: 'fbd' }) + store.getState().fbdFlowActions.setFlowUpdated({ editorName: 'FbdMain', updated: true }) + + scheduleFlowWriteBack(getState, 'FbdMain', 'fbd') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + const body = store.getState().project.data.pous.find((p) => p.name === 'FbdMain')?.body.value as + | { rung?: unknown; updated?: unknown } + | undefined + expect(body?.rung).toBeDefined() + expect(body && 'updated' in body).toBe(false) + expect(store.getState().fbdFlows.find((f) => f.name === 'FbdMain')?.updated).toBe(false) + }) + + it('skips execution when the flow is gone or no longer marked updated', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Ghost', 'ld') + scheduleFlowWriteBack(getState, 'Main', 'ld') + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: false }) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(0) + }) + + it('refuses to persist a flow that fails schema validation', () => { + makeDirtyLadderPou('Main') + // Replace the flow with a malformed one (rung missing bounds/viewport). + // addLadderFlow resets `updated` on store, so re-flag it afterwards. + store.getState().ladderFlowActions.addLadderFlow({ + name: 'Main', + updated: true, + rungs: [{ id: 'r1', nodes: [], edges: [] }], + } as unknown as LadderFlowType) + store.getState().ladderFlowActions.setFlowUpdated({ editorName: 'Main', updated: true }) + + scheduleFlowWriteBack(getState, 'Main', 'ld') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(0) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + }) + }) + + describe('flushFlowWriteBacks', () => { + it('runs a pending write-back immediately', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Main', 'ld') + + flushFlowWriteBacks(getState) + + expect(ladderBody('Main')?.rungs).toHaveLength(1) + // The timer was cancelled along with the flush — nothing fires later. + const bodyAfterFlush = ladderBody('Main') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + expect(ladderBody('Main')).toBe(bodyAfterFlush) + }) + + it('scopes the flush to one POU when a name is given', () => { + makeDirtyLadderPou('A') + makeDirtyLadderPou('B') + scheduleFlowWriteBack(getState, 'A', 'ld') + scheduleFlowWriteBack(getState, 'B', 'ld') + + flushFlowWriteBacks(getState, 'A') + + expect(ladderBody('A')?.rungs).toHaveLength(1) + expect(ladderBody('B')?.rungs).toHaveLength(0) + + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + expect(ladderBody('B')?.rungs).toHaveLength(1) + }) + + it('is a no-op when nothing is pending', () => { + makeDirtyLadderPou('Main') + const bodyBefore = ladderBody('Main') + + flushFlowWriteBacks(getState) + + expect(ladderBody('Main')).toBe(bodyBefore) + }) + }) + + describe('cancelFlowWriteBacks', () => { + it('drops pending write-backs without running them', () => { + makeDirtyLadderPou('Main') + scheduleFlowWriteBack(getState, 'Main', 'ld') + + cancelFlowWriteBacks() + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('Main')?.rungs).toHaveLength(0) + expect(store.getState().ladderFlows.find((f) => f.name === 'Main')?.updated).toBe(true) + }) + + it('scopes the cancel to one POU when a name is given', () => { + makeDirtyLadderPou('A') + makeDirtyLadderPou('B') + scheduleFlowWriteBack(getState, 'A', 'ld') + scheduleFlowWriteBack(getState, 'B', 'ld') + + cancelFlowWriteBacks('A') + vi.advanceTimersByTime(FLOW_WRITEBACK_DEBOUNCE_MS) + + expect(ladderBody('A')?.rungs).toHaveLength(0) + expect(ladderBody('B')?.rungs).toHaveLength(1) + }) + }) +}) diff --git a/src/frontend/store/slices/shared/flow-writeback.ts b/src/frontend/store/slices/shared/flow-writeback.ts new file mode 100644 index 000000000..07e3cd491 --- /dev/null +++ b/src/frontend/store/slices/shared/flow-writeback.ts @@ -0,0 +1,98 @@ +import { zodFBDFlowSchema } from '../fbd' +import { zodLadderFlowSchema } from '../ladder' +import type { SharedRootState } from './types' + +/** + * Debounced write-back of graphical flow state (ladder / FBD) into + * `project.data.pous[].body.value`. + * + * Every graphical edit used to trigger an immediate whole-flow + * `structuredClone` into the project slice (plus a monaco-model-sync sweep + * per write). Edits inside the debounce window now coalesce into a single + * write-back that persists the flow **by reference** — the store is + * immer-managed (frozen, copy-on-write), so the project copy and the live + * flow safely share structure. + * + * A pending timer means `pou.body.value` is momentarily stale, so the rest + * of the app must cooperate: + * - save paths call `flushFlowWriteBacks` before serializing, so a save + * landing inside the debounce window still persists the fresh body; + * - undo/redo and snapshot capture flush the affected POU so a history + * entry never pairs a stale body with a fresh flow; + * - project open cancels pending timers outright — a write-back scheduled + * against the previous project must not fire into the new one. + * + * Execution re-reads the store at fire time and is guarded on + * `flow.updated`, so a flush after save/undo already cleared the flag is a + * no-op. + */ + +export const FLOW_WRITEBACK_DEBOUNCE_MS = 200 + +type FlowLanguage = 'ld' | 'fbd' +type GetWriteBackState = () => SharedRootState + +const pendingWriteBacks = new Map }>() + +function runWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { + const state = getState() + const flow = + language === 'ld' + ? state.ladderFlows.find((f) => f.name === pouName) + : state.fbdFlows.find((f) => f.name === pouName) + if (!flow?.updated) return + + // Validate with zod but persist the raw object (minus the transient + // `updated` flag). Using the parsed result would silently strip every + // field not declared in the schema and reorder keys to schema order, + // byte-drifting the serialized POU vs. the loaded disk copy — phantom + // "Modified" entries in Source Control (see DOPE-477). + const schema = language === 'ld' ? zodLadderFlowSchema : zodFBDFlowSchema + if (!schema.safeParse(flow).success) return + + const { updated: _updated, ...flowBody } = flow + state.projectActions.updatePou({ name: pouName, content: { language, value: flowBody } }) + + const flowActions = language === 'ld' ? state.ladderFlowActions : state.fbdFlowActions + flowActions.setFlowUpdated({ editorName: pouName, updated: false }) +} + +/** + * Mark the POU dirty now and queue its write-back. Edits landing while a + * timer is pending coalesce into it — the executor reads the store at fire + * time, so it always persists the latest flow. + */ +export function scheduleFlowWriteBack(getState: GetWriteBackState, pouName: string, language: FlowLanguage): void { + const state = getState() + // Dirty-marking keeps its immediate, per-edit timing; only the expensive + // updatePou is deferred. The debugger drives node values through the same + // flow state without making the file unsaved, hence the gate. + if (!state.workspace.isDebuggerVisible) { + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(pouName) + } + if (pendingWriteBacks.has(pouName)) return + const timer = setTimeout(() => { + pendingWriteBacks.delete(pouName) + runWriteBack(getState, pouName, language) + }, FLOW_WRITEBACK_DEBOUNCE_MS) + pendingWriteBacks.set(pouName, { language, timer }) +} + +/** Run pending write-backs immediately — all of them, or a single POU's. */ +export function flushFlowWriteBacks(getState: GetWriteBackState, pouName?: string): void { + for (const [name, pending] of [...pendingWriteBacks]) { + if (pouName !== undefined && name !== pouName) continue + clearTimeout(pending.timer) + pendingWriteBacks.delete(name) + runWriteBack(getState, name, pending.language) + } +} + +/** Drop pending write-backs without running them (project open). */ +export function cancelFlowWriteBacks(pouName?: string): void { + for (const [name, pending] of [...pendingWriteBacks]) { + if (pouName !== undefined && name !== pouName) continue + clearTimeout(pending.timer) + pendingWriteBacks.delete(name) + } +} diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 27933fd89..3d1219d18 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -1,7 +1,6 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' -import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' @@ -11,7 +10,6 @@ import { restampFlowLibraryVariants } from '../../../utils/PLC/restamp-library-v import { collectAllSlaveNames } from '../../../utils/unique-slave-name' import type { FBDFlowType } from '../fbd' import type { FileSliceDataObject } from '../file' -import type { HistorySnapshot } from '../history' import type { LadderFlowType } from '../ladder' import type { TabsProps } from '../tabs' import { @@ -20,7 +18,8 @@ import { CreateServerEditor, LIBRARY_MANIFEST_TAB_NAME, } from '../tabs/utils' -import type { SharedRootState, SharedSlice } from './types' +import { cancelFlowWriteBacks, flushFlowWriteBacks } from './flow-writeback' +import type { PouHistorySnapshot, SharedRootState, SharedSlice } from './types' import { createDatatypeObject, createEditorObjectForDatatype, createEditorObjectForPou, createPouObject } from './utils' const MAX_HISTORY_SIZE = 50 @@ -587,6 +586,10 @@ const createSharedSlice: StateCreator = (s }, handleOpenProjectResponse: (data) => { + // A write-back scheduled against the previous project must not fire + // into the one being opened (project load flips `updated` flags as a + // side effect, which would let a stale timer persist a fresh flow). + cancelFlowWriteBacks() getState().sharedWorkspaceActions.clearStatesOnCloseProject() getState().workspaceActions.setEditingState('saved') // Any in-place reload (branch switch, restore, discard, stash) can move @@ -987,6 +990,9 @@ const createSharedSlice: StateCreator = (s }, undo: (pouName) => { + // A debounced graphical write-back may still be pending — flush it so + // the redo snapshot below can't pair a stale body with a fresh flow. + flushFlowWriteBacks(getState, pouName) const state = getState() const history = state.undoRedo[pouName] if (!history || history.past.length === 0) return @@ -995,17 +1001,15 @@ const createSharedSlice: StateCreator = (s const pou = state.project.data.pous.find((p) => p.name === pouName) if (!pou) return - // Save current state to future (deep copy to avoid mutation) - const ladderFlow = state.ladderFlows.find((f) => f.name === pouName) - const fbdFlow = state.fbdFlows.find((f) => f.name === pouName) - const currentSnapshot: HistorySnapshot = { - variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])) as PLCVariable[], - body: JSON.parse(JSON.stringify(pou.body.value)) as unknown, - ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined, - fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined, - globalVariables: JSON.parse( - JSON.stringify(state.project.data.configurations.resource.globalVariables), - ) as PLCVariable[], + // Save current state to future. Plain references — the store is + // immer-managed (frozen, copy-on-write), so later edits can never + // reach a captured snapshot. + const currentSnapshot: PouHistorySnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, } setState( @@ -1044,6 +1048,8 @@ const createSharedSlice: StateCreator = (s }, redo: (pouName) => { + // See undo — same pending write-back consistency requirement. + flushFlowWriteBacks(getState, pouName) const state = getState() const history = state.undoRedo[pouName] if (!history || history.future.length === 0) return @@ -1052,17 +1058,13 @@ const createSharedSlice: StateCreator = (s const pou = state.project.data.pous.find((p) => p.name === pouName) if (!pou) return - // Save current state to past (deep copy to avoid mutation) - const ladderFlow = state.ladderFlows.find((f) => f.name === pouName) - const fbdFlow = state.fbdFlows.find((f) => f.name === pouName) - const currentSnapshot: HistorySnapshot = { - variables: JSON.parse(JSON.stringify(pou.interface?.variables ?? [])) as PLCVariable[], - body: JSON.parse(JSON.stringify(pou.body.value)) as unknown, - ladderFlow: ladderFlow ? JSON.parse(JSON.stringify(ladderFlow)) : undefined, - fbdFlow: fbdFlow ? JSON.parse(JSON.stringify(fbdFlow)) : undefined, - globalVariables: JSON.parse( - JSON.stringify(state.project.data.configurations.resource.globalVariables), - ) as PLCVariable[], + // Save current state to past. Plain references — see undo. + const currentSnapshot: PouHistorySnapshot = { + variables: pou.interface?.variables ?? [], + body: pou.body.value, + ladderFlow: state.ladderFlows.find((f) => f.name === pouName), + fbdFlow: state.fbdFlows.find((f) => f.name === pouName), + globalVariables: state.project.data.configurations.resource.globalVariables, } setState(