The new react-hooks lint rule "Calling setState synchronously within an effect can trigger cascading renders" flagged real anti-patterns in four spots. Refactored each per the rule's intent (derive during render, or use useSyncExternalStore for external subscriptions). 1. hooks/useMediaQuery.ts — replaced the useState + useEffect pair with useSyncExternalStore. That's the canonical React hook for subscribing to external stores (matchMedia in this case) without mirroring into local state via an effect. Snapshot/getServerSnapshot pair preserves the SSR-safe behaviour. 2. components/network/nodes/DeviceNode.tsx — the prop-sync useEffect that copied nodeData.label into labelValue was redundant. labelValue is the EDIT BUFFER; while not editing, the displayed span now reads nodeData.label directly. The buffer is initialized only when an edit session starts (onDoubleClick). 3. components/network/nodes/GroupNode.tsx — same pattern, same fix. 4. components/dashboard/TicketQueue.tsx — the setTickets([]) + setLoading(true) + fetchTickets() chain in the effect was the cascade. Pushed those writes inside fetchTickets (after the function boundary, so they batch with the eventual setTickets(result)). Added a request-id ref so a slow first response can't overwrite a fast second one. Frontend lint: 20 errors → 0 errors. tsc -b clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
import { memo, useState, useRef, useEffect } from 'react'
|
|
import { NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
|
|
import type { GroupNodeData } from '@/types/network-diagram'
|
|
|
|
const GROUP_COLORS: Record<string, string> = {
|
|
subnet: '#60a5fa',
|
|
vlan: '#a78bfa',
|
|
site: '#34d399',
|
|
dmz: '#f87171',
|
|
custom: '#94a3b8',
|
|
}
|
|
|
|
const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
|
|
const groupData = data as GroupNodeData
|
|
const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom
|
|
const [editing, setEditing] = useState(false)
|
|
const [labelValue, setLabelValue] = useState(groupData.label ?? '')
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const { updateNodeData } = useReactFlow()
|
|
|
|
useEffect(() => {
|
|
if (editing) inputRef.current?.focus()
|
|
}, [editing])
|
|
|
|
// While not editing, the displayed label is derived directly from
|
|
// groupData.label — no effect-driven sync needed. labelValue holds the
|
|
// edit buffer only and is reset when an edit session starts.
|
|
|
|
const handleLabelCommit = () => {
|
|
setEditing(false)
|
|
if (labelValue !== groupData.label) {
|
|
updateNodeData(id, { ...groupData, label: labelValue })
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<NodeResizer
|
|
isVisible={selected}
|
|
minWidth={120}
|
|
minHeight={80}
|
|
lineStyle={{ border: `1px solid ${color}` }}
|
|
handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
|
|
/>
|
|
<div
|
|
className="w-full h-full rounded-lg relative"
|
|
style={{
|
|
border: `1.5px dashed ${color}`,
|
|
background: `${color}0d`,
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
<div className="absolute top-0 left-2 -translate-y-full pb-0.5">
|
|
{editing ? (
|
|
<input
|
|
ref={inputRef}
|
|
value={labelValue}
|
|
onChange={e => setLabelValue(e.target.value)}
|
|
onBlur={handleLabelCommit}
|
|
onKeyDown={e => {
|
|
if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit()
|
|
e.stopPropagation()
|
|
}}
|
|
className="rounded-sm px-1.5 py-0.5 text-[11px] font-semibold bg-card/90 border-none outline-none min-w-[40px] max-w-[200px]"
|
|
style={{ color }}
|
|
/>
|
|
) : (
|
|
<span
|
|
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
|
|
style={{ color }}
|
|
onDoubleClick={() => {
|
|
setLabelValue(groupData.label ?? '')
|
|
setEditing(true)
|
|
}}
|
|
>
|
|
{(groupData.label ?? '') || groupData.groupType}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
GroupNodeComponent.displayName = 'GroupNode'
|
|
|
|
export const GroupNode = memo(GroupNodeComponent)
|
|
export default GroupNode
|