+ {/* eslint-disable-next-line @next/next/no-img-element -- QR-Code kommt als base64-data-URL vom Backend; next/image bringt hier keinen Nutzen (keine Optimierung/kein Netzwerk-Request) */}

(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
+ // eslint-disable-next-line react-hooks/purity -- shadcn/ui-Komponente, keine Custom-Aenderung laut Projektkonvention (CLAUDE.md); zufaellige Skeleton-Breite ist hier gewollt und durch useMemo stabil
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx
index 2b0fe1d..16c756e 100644
--- a/src/hooks/use-mobile.tsx
+++ b/src/hooks/use-mobile.tsx
@@ -2,18 +2,26 @@ import * as React from "react"
const MOBILE_BREAKPOINT = 768
-export function useIsMobile() {
- const [isMobile, setIsMobile] = React.useState
(undefined)
-
- React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
- const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- }
- mql.addEventListener("change", onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
- return () => mql.removeEventListener("change", onChange)
- }, [])
-
- return !!isMobile
+function subscribe(onChange: () => void) {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ mql.addEventListener("change", onChange)
+ return () => mql.removeEventListener("change", onChange)
+}
+
+function getSnapshot() {
+ return window.innerWidth < MOBILE_BREAKPOINT
+}
+
+// Auf dem Server (und im ersten Hydration-Render) gilt "nicht mobil" —
+// identisch zum vorherigen Verhalten, bei dem der State initial undefined
+// war und `!!isMobile` false ergab.
+function getServerSnapshot() {
+ return false
+}
+
+export function useIsMobile() {
+ // useSyncExternalStore statt useEffect+setState: der Viewport ist ein
+ // externer Store. Das ist der von React vorgesehene Weg und vermeidet den
+ // zusaetzlichen Render-Durchlauf (react-hooks/set-state-in-effect).
+ return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
diff --git a/src/hooks/useAdminDashboard.ts b/src/hooks/useAdminDashboard.ts
index 04b1349..2829091 100644
--- a/src/hooks/useAdminDashboard.ts
+++ b/src/hooks/useAdminDashboard.ts
@@ -52,9 +52,14 @@ export function useAdminDashboard(active: boolean) {
useEffect(() => {
if (!active) return;
- loadDashboard();
+ // Initialer Ladeaufruf + Countdown-Reset in async-Funktion gekapselt,
+ // damit die State-Updates nicht synchron im Effect-Body liegen
+ // (react-hooks/set-state-in-effect). Ablauf unveraendert.
+ void (async () => {
+ loadDashboard();
+ setCountdown(30);
+ })();
- setCountdown(30);
dashIntervalRef.current = setInterval(() => {
loadDashboard();
setCountdown(30);
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
index bb0d36c..b999226 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/useAuth.ts
@@ -55,7 +55,10 @@ export function useAuth(requireRole?: "admin" | "domain_admin" | "superadmin" |
}, [router, requireRole, loginPage]);
useEffect(() => {
- checkAuth();
+ // In async-Funktion gekapselt, damit der (bei gecachtem User synchrone)
+ // State-Update nicht direkt im Effect-Body liegt
+ // (react-hooks/set-state-in-effect). Ablauf unveraendert.
+ void (async () => { await checkAuth(); })();
}, [checkAuth]);
const refresh = useCallback(async () => {
diff --git a/src/hooks/useImapAccounts.ts b/src/hooks/useImapAccounts.ts
index bc65f20..6e41f9f 100644
--- a/src/hooks/useImapAccounts.ts
+++ b/src/hooks/useImapAccounts.ts
@@ -68,7 +68,11 @@ export function useImapAccounts(user: unknown) {
}, []);
useEffect(() => {
- if (user) loadAccounts();
+ if (!user) return;
+ // Ladeaufruf in async-Funktion gekapselt, damit der Loading-State nicht
+ // synchron im Effect-Body gesetzt wird (react-hooks/set-state-in-effect).
+ // Ablauf und Ladeanzeige bleiben unveraendert.
+ void (async () => { await loadAccounts(); })();
}, [user, loadAccounts]);
// Start polling for running accounts (import or sync)
diff --git a/src/hooks/useSavedSearches.ts b/src/hooks/useSavedSearches.ts
index cdae68c..02e8455 100644
--- a/src/hooks/useSavedSearches.ts
+++ b/src/hooks/useSavedSearches.ts
@@ -67,11 +67,26 @@ export function useSavedSearches(params: UseSavedSearchesParams) {
// Load saved searches on mount
useEffect(() => {
if (!user || user.role === "superadmin") return;
- setSavedLoading(true);
- listSavedSearches()
- .then((list) => setSavedSearches(list || []))
- .catch(() => setSavedSearches([]))
- .finally(() => setSavedLoading(false));
+ let cancelled = false;
+ // Laden in einer async-Funktion gekapselt (react-hooks/set-state-in-effect):
+ // die State-Updates liegen damit nicht mehr direkt im Effect-Body.
+ // Zusaetzlich Cancel-Guard, damit ein Wechsel des Users kein
+ // veraltetes Ergebnis mehr schreibt. Ladeverhalten unveraendert.
+ async function loadSaved() {
+ setSavedLoading(true);
+ try {
+ const list = await listSavedSearches();
+ if (!cancelled) setSavedSearches(list || []);
+ } catch {
+ if (!cancelled) setSavedSearches([]);
+ } finally {
+ if (!cancelled) setSavedLoading(false);
+ }
+ }
+ void loadSaved();
+ return () => {
+ cancelled = true;
+ };
}, [user]);
function buildCurrentQuery(): Record {
diff --git a/src/hooks/useSearch.ts b/src/hooks/useSearch.ts
index 9cdf89b..9fb1609 100644
--- a/src/hooks/useSearch.ts
+++ b/src/hooks/useSearch.ts
@@ -35,16 +35,22 @@ export function useSearch(user: SearchUser, pageSize: number) {
hasAttachment,
pageSize,
});
- filtersRef.current = {
- query,
- fromFilter,
- toFilter,
- dateFrom,
- dateTo,
- sort,
- hasAttachment,
- pageSize,
- };
+ // Der Ref darf nicht waehrend des Renders beschrieben werden
+ // (react-hooks/refs) — die Synchronisation passiert daher in einem Effect
+ // ohne Deps, also nach jedem Commit. doSearch liest den Ref nur aus
+ // Event-Handlern/Effects, also immer nach dem Commit.
+ useEffect(() => {
+ filtersRef.current = {
+ query,
+ fromFilter,
+ toFilter,
+ dateFrom,
+ dateTo,
+ sort,
+ hasAttachment,
+ pageSize,
+ };
+ });
// Schreibt den Suchbegriff synchron in den Ref, damit ein direkt danach
// ausgeloestes doSearch() (z.B. Enter waehrend eines laufenden Debounce)
@@ -87,19 +93,30 @@ export function useSearch(user: SearchUser, pageSize: number) {
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
useEffect(() => {
if (!user || user.role === "superadmin") return;
- setSearching(true);
- searchEmails({ page: 1, page_size: pageSize })
- .then((res) => {
+ let cancelled = false;
+ // In async-Funktion gekapselt (react-hooks/set-state-in-effect) plus
+ // Cancel-Guard gegen veraltete Antworten. Ablauf unveraendert.
+ async function loadInitial() {
+ setSearching(true);
+ try {
+ const res = await searchEmails({ page: 1, page_size: pageSize });
+ if (cancelled) return;
setResults(res.hits || []);
setTotal(res.total);
setPage(1);
setSearched(true);
- })
- .catch(() => {
+ } catch {
+ if (cancelled) return;
setResults([]);
setTotal(0);
- })
- .finally(() => setSearching(false));
+ } finally {
+ if (!cancelled) setSearching(false);
+ }
+ }
+ void loadInitial();
+ return () => {
+ cancelled = true;
+ };
}, [user, pageSize]);
return {
diff --git a/src/hooks/useSystemInfo.ts b/src/hooks/useSystemInfo.ts
index 59c3fac..991f31e 100644
--- a/src/hooks/useSystemInfo.ts
+++ b/src/hooks/useSystemInfo.ts
@@ -9,7 +9,8 @@ export function useSystemInfo() {
useEffect(() => {
let cancelled = false;
- setSystemInfoLoading(true);
+ // Kein setSystemInfoLoading(true) noetig: der Effect laeuft nur beim
+ // Mount (leere Deps) und der Initialwert des States ist bereits true.
getSystemInfo()
.then((info) => {
if (!cancelled) setSystemInfo(info);