From 3f661d097deb16be0354ff01b256fe47d374418a Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 31 Jul 2026 10:11:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20hooks-refs=20batch=202.1=20?= =?UTF-8?q?=E2=80=94=20useLatest=20helper;=20useAuthenticatedFetch=20+=20p?= =?UTF-8?q?resence=20off=20render-phase=20ref=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render-phase ref mirror writes tear under concurrent rendering (react-hooks/refs). New hooks/useLatest.ts syncs the mirror in a passive effect instead; refs from custom hooks are not linter-known-stable, so consumers list them in dep arrays (identity is stable — no re-creation). Lint ratchet 141 -> 139. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- .../app/admin/presence/page.tsx | 8 +++--- .../hooks/useAuthenticatedFetch.ts | 6 ++--- .../autopilot-monitor-web/hooks/useLatest.ts | 25 +++++++++++++++++++ 4 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/hooks/useLatest.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885cc924..95a08af5 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 141 + run: npm run lint -- --max-warnings 139 - name: Tests run: npm test diff --git a/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx index 07524744..314bb926 100644 --- a/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; +import { useLatest } from "@/hooks/useLatest"; import { useAuth } from "../../../contexts/AuthContext"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -58,8 +59,7 @@ export default function PresencePage() { const [lastUpdated, setLastUpdated] = useState(null); // Keep the latest window value available to the interval callback without re-arming the timer. - const windowRef = useRef(windowMinutes); - windowRef.current = windowMinutes; + const windowRef = useLatest(windowMinutes); const fetchPresence = useCallback( async (showRefreshing = false) => { @@ -87,7 +87,7 @@ export default function PresencePage() { setRefreshing(false); } }, - [getAccessToken], + [getAccessToken, windowRef], ); // Initial load + whenever the window changes. diff --git a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts index 1f5439ec..32d1fa4d 100644 --- a/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts +++ b/src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useRef } from "react"; +import { useLatest } from "@/hooks/useLatest"; import { useAuth } from "@/contexts/AuthContext"; import { useNotifications } from "@/contexts/NotificationContext"; import { @@ -47,8 +48,7 @@ export function useAuthenticatedFetch( const [error, setError] = useState(null); // Stabilize callback refs so `execute` identity doesn't change on every render - const optionsRef = useRef(options); - optionsRef.current = options; + const optionsRef = useLatest(options); // Latest-wins guard: when execute() is called again while a previous request is still in // flight (e.g. the tenant-scope selector seeds a persisted foreign tenant right after mount, @@ -140,7 +140,7 @@ export function useAuthenticatedFetch( } } }, - [getAccessToken, addNotification], + [getAccessToken, addNotification, optionsRef], ); return { data, loading, error, execute, clearError, setData }; diff --git a/src/Web/autopilot-monitor-web/hooks/useLatest.ts b/src/Web/autopilot-monitor-web/hooks/useLatest.ts new file mode 100644 index 00000000..be18f4fc --- /dev/null +++ b/src/Web/autopilot-monitor-web/hooks/useLatest.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useRef, type RefObject } from "react"; + +/** + * Returns a ref that always holds the latest `value`, synced in a passive effect + * (never during render — render-phase ref writes tear under concurrent rendering, + * flagged by react-hooks/refs). + * + * Use for long-lived closures (SignalR handlers, fetch callbacks, interval timers) + * that must see current props/state without being re-created per render. + * + * Timing contract: `ref.current` updates AFTER commit. That is safe for async + * callbacks and event handlers, and for sibling effects declared after the hook + * call (effects flush in registration order). Do NOT read it during render, and + * do not rely on it from layout effects or child-component effects — those run + * before this hook's sync effect and would observe the previous render's value. + */ +export function useLatest(value: T): RefObject { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }); + return ref; +}