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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-06 15:10:04 +02:00
parent 7636a00e24
commit c421b81751
13 changed files with 49 additions and 31 deletions
+1 -1
View File
@@ -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
@@ -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) {
@@ -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++;
};
}, []);
@@ -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++;
};
}, []);
@@ -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
@@ -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(() => {
@@ -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++;
};
}, []);
@@ -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(() => {
@@ -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) => {
@@ -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,
@@ -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)
@@ -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;
@@ -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) {