fix(canvas): drive autosave debounce off an edit counter
useAutosave reset its debounce whenever the nodes/edges array reference changed. Live status polling (setNodeStatus) returns a fresh nodes array on every message without dirtying the canvas, so during active monitoring the status churn kept re-arming the timer faster than the delay and the silent save could be starved indefinitely — the user thinks autosave ran, but it never fired. Select-only changes reset it the same way. Add a monotonic editSeq counter that bumps only on real user edits. The store's set() is wrapped to increment editSeq whenever an update flips hasUnsavedChanges to true, so it stays centralised instead of touching every mutating action. onNodesChange/onEdgesChange now set the dirty flag only when a genuine edit occurred (not on select/measure), so those no longer bump the counter. App keys the autosave changeSignals on [editSeq] instead of [nodes, edges]. Add a regression test asserting editSeq bumps on add/update but not on setNodeStatus, select, or initial dimensions measure. ha-relevant: maybe
This commit is contained in:
@@ -55,7 +55,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
@@ -142,7 +142,10 @@ export default function App() {
|
||||
delaySeconds: autosave.delay,
|
||||
hasUnsavedChanges,
|
||||
designId: loadedDesignId,
|
||||
changeSignals: [nodes, edges],
|
||||
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
|
||||
// identity — live status polling churns those arrays without a user edit and
|
||||
// would otherwise keep re-arming (and starving) the timer during monitoring.
|
||||
changeSignals: [editSeq],
|
||||
getLiveDesignId: () => loadedDesignIdRef.current,
|
||||
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ function resetStore() {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
editSeq: 0,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
@@ -97,6 +98,34 @@ describe('canvasStore — nodes', () => {
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('editSeq bumps on a real user edit but not on status/select/measure churn', () => {
|
||||
const seq = () => useCanvasStore.getState().editSeq
|
||||
|
||||
// Real edit bumps.
|
||||
const before = seq()
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
expect(seq()).toBe(before + 1)
|
||||
|
||||
// Live status update: no bump (not a user edit).
|
||||
const afterAdd = seq()
|
||||
useCanvasStore.getState().setNodeStatus('n1', { status: 'online' })
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Select-only change: no bump.
|
||||
useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }])
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Initial dimensions measure: no bump.
|
||||
useCanvasStore.getState().onNodesChange([
|
||||
{ id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } },
|
||||
])
|
||||
expect(seq()).toBe(afterAdd)
|
||||
|
||||
// Another real edit bumps again.
|
||||
useCanvasStore.getState().updateNode('n1', { label: 'renamed' })
|
||||
expect(seq()).toBe(afterAdd + 1)
|
||||
})
|
||||
|
||||
it('setEditingTextId sets and clears editing text id', () => {
|
||||
const { setEditingTextId } = useCanvasStore.getState()
|
||||
setEditingTextId('t1')
|
||||
|
||||
@@ -109,6 +109,14 @@ interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
hasUnsavedChanges: boolean
|
||||
/**
|
||||
* Monotonic counter incremented on every real user edit (auto-bumped whenever
|
||||
* an action sets hasUnsavedChanges to true). Consumers that need to react to
|
||||
* *edits specifically* — e.g. the autosave debounce — key off this instead of
|
||||
* the nodes/edges array identity, which also churns on live status updates and
|
||||
* selection changes that must NOT reset the debounce.
|
||||
*/
|
||||
editSeq: number
|
||||
selectedNodeId: string | null
|
||||
selectedNodeIds: string[]
|
||||
scanEventTs: number
|
||||
@@ -187,10 +195,33 @@ interface CanvasState {
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
export const useCanvasStore = create<CanvasState>((rawSet) => {
|
||||
// Wrap set so any update that flips hasUnsavedChanges to true also bumps
|
||||
// editSeq. This centralises the "an edit happened" signal instead of touching
|
||||
// every one of the ~two dozen mutating actions. Actions that update state
|
||||
// without dirtying (setNodeStatus, markSaved, loadCanvas, selection) omit
|
||||
// hasUnsavedChanges or set it false, so they never bump the counter.
|
||||
const set: typeof rawSet = ((partial, replace) => {
|
||||
rawSet((state) => {
|
||||
const next = typeof partial === 'function'
|
||||
? (partial as (s: CanvasState) => Partial<CanvasState>)(state)
|
||||
: partial
|
||||
if (
|
||||
next &&
|
||||
typeof next === 'object' &&
|
||||
(next as Partial<CanvasState>).hasUnsavedChanges === true &&
|
||||
!('editSeq' in next)
|
||||
) {
|
||||
return { ...next, editSeq: state.editSeq + 1 }
|
||||
}
|
||||
return next
|
||||
}, replace as false | undefined)
|
||||
}) as typeof rawSet
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
editSeq: 0,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
@@ -347,19 +378,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
// they don't follow a moved node on their own. Translate them by the same
|
||||
// delta the node moved so a clean routing stays clean after a drag (#279).
|
||||
const edges = translateWaypointsForMovedNodes(changes, state.nodes, nodes, state.edges)
|
||||
// Only set hasUnsavedChanges when a real edit occurred, so the set() wrapper
|
||||
// bumps editSeq only then. Selection- or measure-only changes leave the flag
|
||||
// untouched (carried over) and must not reset the autosave debounce.
|
||||
const edited = changes.some(isUserNodeEdit)
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
selectedNodeIds,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some(isUserNodeEdit),
|
||||
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
set((state) => ({
|
||||
set((state) => {
|
||||
const edited = changes.some((c) => c.type !== 'select')
|
||||
return {
|
||||
edges: applyEdgeChanges(changes, state.edges),
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
})),
|
||||
...(edited ? { hasUnsavedChanges: true } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
onConnect: (connection) =>
|
||||
set((state) => {
|
||||
@@ -1013,4 +1051,5 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
})
|
||||
return { nodes, edges, hasUnsavedChanges: true }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user