From c421b81751301a9c07a62e6b4126c7f3faefc1bf Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 6 Aug 2026 15:10:04 +0200 Subject: [PATCH] web: fix all 16 exhaustive-deps lint warnings, ratchet cap 115 -> 99 Wrap ad-hoc fetch closures in useCallback and complete effect deps (SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext, SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert sla's initialLoad to a ref so the fresh-once flag can't retrigger the fetch effect. Document the three intentional live-ref bumps in unmount cleanups (cancellation counters) with justified disables. Also fixes a real stale-closure bug: TenantConfigContext's save callback serialized webhookNotifyOnHardwareRejection without depending on it, so toggling only that setting saved the old value. The 3 react-compiler preserve-manual-memoization infos remain (compiler bailouts, not defects) - cap ratcheted to the exact current count of 99. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- .../metrics/sections/SectionPlatformUsage.tsx | 8 +++---- .../dashboard/hooks/useDashboardSessions.ts | 3 +++ .../app/dashboard/hooks/useDashboardStats.ts | 3 +++ .../app/dashboard/page.tsx | 2 +- .../app/diagnosis/page.tsx | 24 +++++++++---------- .../app/fleet-health/hooks/useFleetHealth.ts | 3 +++ .../app/fleet-health/page.tsx | 6 +++-- .../app/progress/hooks/useProgressSignalR.ts | 13 ++++++---- .../app/settings/TenantConfigContext.tsx | 4 ++-- .../autopilot-monitor-web/app/sla/page.tsx | 8 ++++--- .../app/usage-metrics/page.tsx | 2 +- .../contexts/SignalRContext.tsx | 2 +- 13 files changed, 49 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf006c70..1324d148 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,7 @@ jobs: # in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower # the cap to match; never raise it. - name: Lint - run: npm run lint -- --max-warnings 115 + run: npm run lint -- --max-warnings 99 - name: Tests run: npm test diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx index 38dc6eb6..eb93b5a8 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; import { useAuth } from '../../../../contexts/AuthContext'; @@ -112,7 +112,7 @@ export function SectionPlatformUsage() { const progress = useFetchProgress('platformUsage.lastFreshFetchMs'); const { begin: progressBegin, finish: progressFinish } = progress; - const fetchMetrics = async (showRefreshing = false) => { + const fetchMetrics = useCallback(async (showRefreshing = false) => { let recordEstimate = false; try { if (showRefreshing) { @@ -151,13 +151,13 @@ export function SectionPlatformUsage() { setLoading(false); setRefreshing(false); } - }; + }, [getAccessToken, progressBegin, progressFinish]); useEffect(() => { // Fetch metrics on mount // Authorization is handled by admin layout ProtectedRoute fetchMetrics(); - }, []); + }, [fetchMetrics]); const formatDuration = (minutes: number) => { if (minutes < 60) { diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index 735660ec..88bff0f3 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -374,6 +374,9 @@ export function useDashboardSessions({ // doesn't call setSessions against a torn-down component. useEffect(() => { return () => { + // Intentionally the LIVE ref at unmount time — bumping the counter is the + // cancellation signal; a snapshot taken at mount would cancel nothing. + // eslint-disable-next-line react-hooks/exhaustive-deps loadAllTokenRef.current++; }; }, []); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts index f02f8f43..cdf61900 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts @@ -261,6 +261,9 @@ export function useDashboardStats({ useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + // Intentionally the LIVE ref at unmount time — bumping the generation + // invalidates in-flight fetches; a snapshot taken at mount would not. + // eslint-disable-next-line react-hooks/exhaustive-deps fetchGenRef.current++; }; }, []); diff --git a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx index a22c88a4..7d3b9613 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/page.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/page.tsx @@ -207,7 +207,7 @@ function HomeContent() { console.log('[Home] User has no platform scope, disabling global mode'); setGlobalAdminMode(false); } - }, [user, globalAdminMode]); + }, [user, globalAdminMode, setGlobalAdminMode]); // Clear the tenant filter when cross-tenant mode turns off (refetch is owned by useDashboardSessions). // Keyed on crossTenant (not raw globalAdminMode) so a delegated ("MSP") admin — whose crossTenant is diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index 560fe366..3dbbfab4 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -1,7 +1,7 @@ "use client"; import { sessionUrl } from "@/lib/routes"; -import { Suspense, useEffect, useState, useRef } from "react"; +import { Suspense, useCallback, useEffect, useState, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useSignalR } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; @@ -51,11 +51,11 @@ function DiagnosisContent() { const sessionFetchDone = useRef(false); const analysisFetchDone = useRef(false); - const finishLoadingWhenSettled = () => { + const finishLoadingWhenSettled = useCallback(() => { if (sessionFetchDone.current && analysisFetchDone.current) { setLoading(false); } - }; + }, []); const { on, off, isConnected, joinGroup, leaveGroup } = useSignalR(); const { tenantId } = useTenant(); @@ -66,7 +66,7 @@ function DiagnosisContent() { // Declared ahead of the effects that call them (react-hooks/immutability: // a later declaration would freeze the effect's view of the binding). - const fetchSessionDetails = async () => { + const fetchSessionDetails = useCallback(async () => { try { // Fetch the single session directly instead of pulling the entire session // list and .find()-ing client-side. The backend resolves the tenant via @@ -100,9 +100,9 @@ function DiagnosisContent() { sessionFetchDone.current = true; finishLoadingWhenSettled(); } - }; + }, [sessionId, sessionTenantId, tenantId, globalAdminMode, getAccessToken, addNotification, finishLoadingWhenSettled]); - const fetchEvents = async () => { + const fetchEvents = useCallback(async () => { const effectiveTenantId = sessionTenantId || tenantId; try { const response = await authenticatedFetch( @@ -123,9 +123,9 @@ function DiagnosisContent() { addNotification('error', 'Backend Not Reachable', 'Unable to load session events. Please check your connection.', 'diagnosis-events-error'); } } - }; + }, [sessionId, sessionTenantId, tenantId, getAccessToken, addNotification]); - const fetchAnalysisResults = async () => { + const fetchAnalysisResults = useCallback(async () => { const effectiveTenantId = sessionTenantId || tenantId; try { const response = await authenticatedFetch( @@ -153,7 +153,7 @@ function DiagnosisContent() { analysisFetchDone.current = true; finishLoadingWhenSettled(); } - }; + }, [sessionId, sessionTenantId, tenantId, getAccessToken, addNotification, finishLoadingWhenSettled]); useEffect(() => { if (!sessionId) return; @@ -180,13 +180,13 @@ function DiagnosisContent() { } fetchSessionDetails(); - }, [sessionId, tenantId, globalAdminMode]); + }, [sessionId, tenantId, globalAdminMode, fetchSessionDetails]); useEffect(() => { if (sessionTenantId && sessionId) { Promise.all([fetchEvents(), fetchAnalysisResults()]); } - }, [sessionTenantId, sessionId]); + }, [sessionTenantId, sessionId, fetchEvents, fetchAnalysisResults]); // SignalR groups - subscribe-then-fetch pattern useEffect(() => { @@ -207,7 +207,7 @@ function DiagnosisContent() { hasJoinedGroups.current = false; } }; - }, [sessionId, isConnected, sessionTenantId, tenantId]); + }, [sessionId, isConnected, sessionTenantId, tenantId, joinGroup, leaveGroup, fetchEvents, fetchAnalysisResults]); // Real-time analysis updates useEffect(() => { diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts index e55d6278..f2ab36e6 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts +++ b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts @@ -257,6 +257,9 @@ export function useFleetHealth({ useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); + // Intentionally the LIVE ref at unmount time — bumping the generation + // invalidates in-flight fetches; a snapshot taken at mount would not. + // eslint-disable-next-line react-hooks/exhaustive-deps fetchGenRef.current++; }; }, []); diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index e8b8a23c..fe130cbb 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -168,7 +168,7 @@ export default function FleetHealthPage() { hasJoinedGroup.current = false; } }; - }, [isConnected, effectiveTenantId]); + }, [isConnected, effectiveTenantId, joinGroup, leaveGroup]); // Server payload, with presentation-friendly defaults so the JSX renders // without null guards once loading clears. `avgDuration` keeps the card's @@ -199,7 +199,9 @@ export default function FleetHealthPage() { return `/dashboard?${params.toString()}`; }; const topFailingModels = data?.topFailingModels ?? []; - const failureReasons = data?.failureReasons ?? []; + // Memoized (unlike the sibling defaults above) because it feeds the maxFailureCount + // useMemo below — a fresh [] each render would invalidate that memo every time. + const failureReasons = useMemo(() => data?.failureReasons ?? [], [data]); // Timeline points with presentation labels derived from the UTC date string. const dailyData = useMemo(() => { diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts index ceb60fa9..1fab182b 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts @@ -38,10 +38,15 @@ export function useProgressSignalR({ }: UseProgressSignalRParams): void { const { on, off, isConnected, joinGroup, leaveGroup } = signalR; - useEffect(() => { - if (!isConnected || !session) return; + // Primitives extracted so the join effect keys on identity of the selected + // session, not object identity — a refetched session object must not rejoin. + const sessionId = session?.sessionId; + const sessionTenantId = session?.tenantId; - const sessionGroup = `session-${session.tenantId}-${session.sessionId}`; + useEffect(() => { + if (!isConnected || !sessionId || !sessionTenantId) return; + + const sessionGroup = `session-${sessionTenantId}-${sessionId}`; console.log("[Progress] Joining session group:", sessionGroup); joinGroup(sessionGroup); @@ -49,7 +54,7 @@ export function useProgressSignalR({ console.log("[Progress] Leaving session group:", sessionGroup); leaveGroup(sessionGroup); }; - }, [isConnected, session?.sessionId, session?.tenantId, joinGroup, leaveGroup]); + }, [isConnected, sessionId, sessionTenantId, joinGroup, leaveGroup]); useEffect(() => { const scheduleRefetch = (source: string, sessionId: string) => { diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index e39cd814..31378424 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -616,7 +616,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } }; fetchConfiguration(); - }, [tenantId]); + }, [tenantId, getAccessToken, addNotification, user?.isTenantAdmin, user?.isGlobalAdmin]); // ----------------------------------------------------------------------- // Fetch admins @@ -813,7 +813,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } } }, [ tenantId, config, canEditConfig, getAccessToken, addNotification, - manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, + manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds, contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary, diff --git a/src/Web/autopilot-monitor-web/app/sla/page.tsx b/src/Web/autopilot-monitor-web/app/sla/page.tsx index 4d51fc43..e2262587 100644 --- a/src/Web/autopilot-monitor-web/app/sla/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sla/page.tsx @@ -118,7 +118,9 @@ export default function SlaPage() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [months, setMonths] = useState(3); - const [initialLoad, setInitialLoad] = useState(true); + // First fetch bypasses the server cache (fresh=true), all later ones use it. A ref, not + // state: it is only read inside fetchMetrics and must not retrigger the fetch effect. + const initialLoadRef = useRef(true); // Global admin tenant scope (tenant list, selector state, override/effective tenant) const scope = useGlobalAdminScope(); @@ -137,8 +139,8 @@ export default function SlaPage() { if (showRefreshing) setRefreshing(true); else setLoading(true); - const useFresh = initialLoad; - if (initialLoad) setInitialLoad(false); + const useFresh = initialLoadRef.current; + initialLoadRef.current = false; const url = isGlobalOverride ? api.metrics.globalSla(effectiveTenantId, months, useFresh) diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index 184bcab7..1ac6735a 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -149,7 +149,7 @@ export default function UsageMetricsPage() { useEffect(() => { if (!effectiveTenantId) return; fetchMetrics(); - }, [effectiveTenantId]); + }, [effectiveTenantId, fetchMetrics]); const selectedTenantName = tenants.find(t => t.tenantId === selectedTenantId)?.domainName; diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 3fcfac1f..1a1604d1 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -222,7 +222,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { connectionRef.current = null; } }; - }, [isAuthenticated, getAccessToken]); + }, [isAuthenticated, getAccessToken, syncJoinedGroups]); const on = (eventName: string, callback: SignalRHandler) => { if (connectionRef.current) {