fix(web): hooks-refs batch 2.1 — useLatest helper; useAuthenticatedFetch + presence off render-phase ref writes

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-31 10:11:59 +02:00
parent 3392a23da6
commit 3f661d097d
4 changed files with 33 additions and 8 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 141
run: npm run lint -- --max-warnings 139
- name: Tests
run: npm test
@@ -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<Date | null>(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.
@@ -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<T = unknown>(
const [error, setError] = useState<string | null>(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<T = unknown>(
}
}
},
[getAccessToken, addNotification],
[getAccessToken, addNotification, optionsRef],
);
return { data, loading, error, execute, clearError, setData };
@@ -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<T>(value: T): RefObject<T> {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
});
return ref;
}