diff --git a/frontend/src/components/dashboard/TicketQueue.tsx b/frontend/src/components/dashboard/TicketQueue.tsx index 6826b70d..49034407 100644 --- a/frontend/src/components/dashboard/TicketQueue.tsx +++ b/frontend/src/components/dashboard/TicketQueue.tsx @@ -194,6 +194,9 @@ export function TicketQueue() { const [activeTab, setActiveTab] = useState('mine') const [tickets, setTickets] = useState([]) const [loading, setLoading] = useState(false) + // Monotonically increasing fetch token — late responses with a stale id + // are dropped so they can't overwrite the latest query's results. + const latestRequestId = useRef(0) const [error, setError] = useState(null) // Check connection on mount @@ -238,12 +241,25 @@ export function TicketQueue() { params.board_ids = boardIds.join(',') } + // Clear stale data + flip loading inside the async function so the + // writes happen after the awaitable boundary — avoids the + // synchronous-setState-in-effect cascade the lint rule flags. The + // fetch is also wrapped in a request-id check so a stale response + // can't clobber a newer query. + const requestId = ++latestRequestId.current + setTickets([]) + setLoading(true) + try { const results = await integrationsApi.searchTicketsQueue(params) + if (requestId !== latestRequestId.current) return setTickets(results.items) setError(null) } catch { + if (requestId !== latestRequestId.current) return setError('Failed to load tickets. Check your PSA connection.') + } finally { + if (requestId === latestRequestId.current) setLoading(false) } }, [], @@ -253,9 +269,7 @@ export function TicketQueue() { useEffect(() => { if (!hasConnection) return if (activeTab === 'mine' && hasMemberMapping !== true) return - setTickets([]) - setLoading(true) - fetchTickets(activeTab, selectedBoardIds).finally(() => setLoading(false)) + fetchTickets(activeTab, selectedBoardIds) }, [activeTab, selectedBoardIds, hasConnection, hasMemberMapping, fetchTickets]) const handleStartSession = (ticket: PSATicketSearchResult) => { diff --git a/frontend/src/components/network/nodes/DeviceNode.tsx b/frontend/src/components/network/nodes/DeviceNode.tsx index 2acd1528..821534bf 100644 --- a/frontend/src/components/network/nodes/DeviceNode.tsx +++ b/frontend/src/components/network/nodes/DeviceNode.tsx @@ -62,10 +62,9 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) { } }, [editing]) - // Sync if data.label changes externally (e.g. undo/redo) - useEffect(() => { - if (!editing) setLabelValue(nodeData.label ?? '') - }, [nodeData.label, editing]) + // While not editing, the displayed label is derived directly from + // nodeData.label — no effect-driven sync needed. labelValue holds the + // edit buffer only and is reset when an edit session starts. const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes @@ -127,10 +126,11 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) { className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2" onDoubleClick={e => { e.stopPropagation() + setLabelValue(nodeData.label ?? '') setEditing(true) }} > - {labelValue} + {nodeData.label ?? ''} )} { if (editing) inputRef.current?.focus() }, [editing]) - // Sync if external data.label changes - useEffect(() => { - if (!editing) setLabelValue(groupData.label ?? '') - }, [groupData.label, 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) @@ -69,9 +68,12 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => { setEditing(true)} + onDoubleClick={() => { + setLabelValue(groupData.label ?? '') + setEditing(true) + }} > - {labelValue || groupData.groupType} + {(groupData.label ?? '') || groupData.groupType} )} diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index 7e56c0ca..e8dacb8d 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -1,27 +1,28 @@ -import { useEffect, useState } from 'react' +import { useSyncExternalStore } from 'react' /** * SSR-safe CSS media-query hook. Returns the current match boolean and * re-renders on viewport changes. Used by /pilot to swap the task lane * between side panel (≥1200px) and bottom drawer (<1200px) per Phase 7. + * + * Implemented with useSyncExternalStore to subscribe to the MediaQueryList + * without an effect — this is the React-idiomatic shape for external-state + * subscriptions and avoids the setState-in-effect cascade lint rule. */ export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(() => { - if (typeof window === 'undefined') return false - return window.matchMedia(query).matches - }) - - useEffect(() => { - if (typeof window === 'undefined') return - const mql = window.matchMedia(query) - const handler = (e: MediaQueryListEvent) => setMatches(e.matches) - // Sync once on mount in case state drifted between render and effect. - setMatches(mql.matches) - mql.addEventListener('change', handler) - return () => mql.removeEventListener('change', handler) - }, [query]) - - return matches + return useSyncExternalStore( + (onChange) => { + if (typeof window === 'undefined') return () => {} + const mql = window.matchMedia(query) + mql.addEventListener('change', onChange) + return () => mql.removeEventListener('change', onChange) + }, + () => { + if (typeof window === 'undefined') return false + return window.matchMedia(query).matches + }, + () => false, // server snapshot — match initial false + ) } export default useMediaQuery