fix(react): remove four setState-in-effect cascades flagged by react-hooks v5
Some checks failed
Mirror to GitHub / mirror (push) Successful in 11s
CI / backend (pull_request) Failing after 11m23s
CI / frontend (pull_request) Failing after 2m42s
CI / e2e (pull_request) Has been skipped

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:
2026-04-25 02:33:13 -04:00
parent b7f8e70be2
commit 920a246d77
4 changed files with 48 additions and 31 deletions

View File

@@ -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