fix(react): remove four setState-in-effect cascades flagged by react-hooks v5
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>
This commit is contained in:
@@ -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<boolean>(() => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user