diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx
index 8504d81..c4d962f 100644
--- a/frontend/src/components/modals/CustomStyleModal.tsx
+++ b/frontend/src/components/modals/CustomStyleModal.tsx
@@ -1,4 +1,4 @@
-import { Fragment, useState, useCallback } from 'react'
+import { Fragment, useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
@@ -160,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.width}
- onChange={(e) => set('width', parseInt(e.target.value) || 0)}
+ onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
@@ -171,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
min={0}
step={10}
value={style.height}
- onChange={(e) => set('height', parseInt(e.target.value) || 0)}
+ onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
@@ -285,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
edges: { ...customStyle.edges },
}))
- const handleOpen = (isOpen: boolean) => {
- if (isOpen) {
- // Reset draft to current saved customStyle on open
+ // Reset the draft to the saved customStyle whenever the modal is (re)opened.
+ // The parent keeps this component mounted and only toggles `open`, so Radix's
+ // onOpenChange never fires for a parent-driven open — we key off the prop edge
+ // instead. Without this, abandoned edits (Cancel) would leak into the next open.
+ useEffect(() => {
+ if (open) {
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
setSelection(null)
- } else {
- onClose()
}
+ // Intentional snapshot-on-open: we don't want live customStyle changes to
+ // clobber an in-progress edit, only a fresh open should reset.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+ const handleOpen = (isOpen: boolean) => {
+ if (!isOpen) onClose()
}
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
index d368a87..13a4d47 100644
--- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
@@ -134,4 +134,23 @@ describe('CustomStyleModal', () => {
fireEvent.change(widthInputs[0], { target: { value: '250' } })
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
})
+
+ it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
+ // Parent keeps the modal mounted and only toggles `open`, so the reset must
+ // happen on the open-prop edge, not via Radix onOpenChange.
+ const { rerender } = render()
+ fireEvent.click(screen.getByRole('button', { name: 'Router' }))
+ const widthInput = screen.getAllByRole('spinbutton')[0]
+ fireEvent.change(widthInput, { target: { value: '250' } })
+ expect((widthInput as HTMLInputElement).value).toBe('250')
+
+ // Cancel = parent flips open → false, then later → true again.
+ rerender()
+ rerender()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Router' }))
+ const reopenedWidth = screen.getAllByRole('spinbutton')[0]
+ // Draft was reset to saved style (default width 0) — edit did not leak.
+ expect((reopenedWidth as HTMLInputElement).value).toBe('0')
+ })
})