diff --git a/src/hooks/useAbortPrevious.js b/src/hooks/useAbortPrevious.js
new file mode 100644
index 0000000..9020eb6
--- /dev/null
+++ b/src/hooks/useAbortPrevious.js
@@ -0,0 +1,17 @@
+import {useCallback, useEffect, useRef} from 'react';
+
+/**
+ * Returns `nextSignal()` for a load that can run again before the last one answered: it aborts the last request and
+ * gives the signal for the next one. Unmounting aborts too. After the await, a load stops when its signal is aborted.
+ */
+export default function useAbortPrevious() {
+ const controller = useRef(null);
+
+ useEffect(() => () => controller.current?.abort(), []);
+
+ return useCallback(() => {
+ controller.current?.abort();
+ controller.current = new AbortController();
+ return controller.current.signal;
+ }, []);
+}
diff --git a/src/hooks/useCurrentUser.test.jsx b/src/hooks/useCurrentUser.test.jsx
new file mode 100644
index 0000000..c640294
--- /dev/null
+++ b/src/hooks/useCurrentUser.test.jsx
@@ -0,0 +1,54 @@
+// @vitest-environment jsdom
+import {afterEach, describe, expect, it} from 'vitest';
+import {cleanup, renderHook, waitFor} from '@testing-library/react';
+import useCurrentUser from './useCurrentUser.js';
+import {CurrentUserProvider, useCurrentUserContext} from '../context/CurrentUserContext.jsx';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+
+const renderUser = () => renderHook(() => useCurrentUser(true), {wrapper: CurrentUserProvider});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+describe('useCurrentUser', () => {
+ it('maps the user from /me and normalizes the profile style', async () => {
+ localStorage.setItem('tbp_session', '1');
+ fetchRoutes({'GET /api/users/me': json(200, {role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
+ settings: {profileStyle: {accent: 'gold', corners: 'round', bannerAppid: 220, backgroundAppid: -5}}})});
+ const {result} = renderUser();
+
+ await waitFor(() => expect(result.current).not.toBeNull());
+ expect(result.current).toEqual({role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
+ profileStyle: {accent: 'violet', corners: 'round', bannerAppid: 220, backgroundAppid: null}});
+ });
+
+ it('uses the role as active role when there is no "view as"', async () => {
+ localStorage.setItem('tbp_session', '1');
+ fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', displayName: 'Lobo', settings: {}})});
+ const {result} = renderUser();
+
+ await waitFor(() => expect(result.current?.activeRole).toBe('USER'));
+ expect(result.current.profileStyle).toEqual({accent: 'violet', corners: 'default', bannerAppid: null,
+ backgroundAppid: null});
+ });
+
+ it('returns null when not enabled', async () => {
+ localStorage.setItem('tbp_session', '1');
+ fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', settings: {}})});
+ const {result} = renderHook(() => ({mapped: useCurrentUser(false), user: useCurrentUserContext().user}),
+ {wrapper: CurrentUserProvider});
+
+ await waitFor(() => expect(result.current.user?.role).toBe('USER'));
+ expect(result.current.mapped).toBeNull();
+ });
+
+ it('returns null without a session and asks for no user', () => {
+ const fetch = fetchRoutes({});
+ const {result} = renderUser();
+
+ expect(result.current).toBeNull();
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/useForceSync.js b/src/hooks/useForceSync.js
index 28986d6..09645d3 100644
--- a/src/hooks/useForceSync.js
+++ b/src/hooks/useForceSync.js
@@ -3,11 +3,9 @@ import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {apiFetch} from '../utils/api.js';
-const RELOAD_DELAY_MS = 4000;
-
/**
- * Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
- * onStarted after a short delay, so the caller can reload once Steam data has arrived.
+ * Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the
+ * `watch` of useLibrarySync, which reloads the data when the run is over.
*/
export default function useForceSync(onStarted) {
const {t} = useTranslation();
@@ -30,7 +28,7 @@ export default function useForceSync(onStarted) {
return;
}
toast.success(t('LIBRARY.SYNC_STARTED'));
- setTimeout(onStarted, RELOAD_DELAY_MS);
+ onStarted();
} catch (err) {
console.error('sync trigger error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
diff --git a/src/hooks/useForceSync.test.jsx b/src/hooks/useForceSync.test.jsx
new file mode 100644
index 0000000..6c6d7e1
--- /dev/null
+++ b/src/hooks/useForceSync.test.jsx
@@ -0,0 +1,66 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, renderHook} from '@testing-library/react';
+import {toast} from 'sonner';
+import useForceSync from './useForceSync.js';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+vi.mock('sonner', () => ({toast: {success: vi.fn(), warning: vi.fn(), error: vi.fn()}}));
+
+const SYNC = 'POST /api/library/sync';
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ localStorage.setItem('tbp_session', '1');
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ vi.clearAllMocks();
+ localStorage.clear();
+});
+
+const trigger = async (onStarted) => {
+ const {result} = renderHook(() => useForceSync(onStarted));
+ await act(() => result.current.trigger());
+ return result;
+};
+
+describe('useForceSync', () => {
+ it('calls onStarted at once after the start, and not again later', async () => {
+ fetchRoutes({[SYNC]: json(202, {})});
+ const onStarted = vi.fn();
+
+ await trigger(onStarted);
+ expect(onStarted).toHaveBeenCalledTimes(1);
+ await act(() => vi.advanceTimersByTimeAsync(10_000));
+
+ expect(onStarted).toHaveBeenCalledTimes(1);
+ expect(toast.success).toHaveBeenCalledWith('LIBRARY.SYNC_STARTED');
+ });
+
+ it('says how long to wait on a 429', async () => {
+ fetchRoutes({[SYNC]: json(429, {retryAfterSeconds: 42})});
+ const onStarted = vi.fn();
+
+ await trigger(onStarted);
+
+ expect(toast.warning).toHaveBeenCalledWith('LIBRARY.SYNC_COOLDOWN');
+ expect(onStarted).not.toHaveBeenCalled();
+ });
+
+ it('says so when the start fails', async () => {
+ fetchRoutes({[SYNC]: json(500, {})});
+ await trigger(vi.fn());
+ expect(toast.error).toHaveBeenCalledWith('LIBRARY.SYNC_FAILED');
+ });
+
+ it('says so when the network is down', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ fetchRoutes({[SYNC]: () => Promise.reject(new TypeError('Failed to fetch'))});
+ await trigger(vi.fn());
+ expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
+ });
+});
diff --git a/src/hooks/useLibrarySync.js b/src/hooks/useLibrarySync.js
index db618bf..0570967 100644
--- a/src/hooks/useLibrarySync.js
+++ b/src/hooks/useLibrarySync.js
@@ -1,4 +1,4 @@
-import {useEffect, useRef, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
@@ -7,28 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000;
const INITIAL_SYNC_GRACE_MS = 15_000;
/**
- * Polls /api/library/sync-status while a sync is running (or expected),
- * and fires onComplete once when the run transitions to OK / FAILED.
+ * Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
+ * A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
+ * user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
*
* @param {Object} opts
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
* gap between async sync dispatch and first DB write)
- * @param {() => void} opts.onComplete - called once per transition to OK/FAILED
- * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
+ * @param {() => void} opts.onComplete - called once per run that ends
+ * @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
+ * watch: () => void}}
*/
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
- const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
- const previousRunningRef = useRef(null);
+ const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
+ // count starts polling again; since is the last known end, so a new one shows that the started run is over
+ const [watched, setWatched] = useState({count: 0, since: undefined});
+ const lastSyncedAtRef = useRef(undefined);
+ // The count of the last watch whose run ended; a later restart of the polling must not report that run again.
+ const endedWatch = useRef(0);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
+ const watch = useCallback(() => {
+ setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current}));
+ }, []);
+
useEffect(() => {
if (!enabled) return undefined;
let cancelled = false;
let timer = null;
+ let wasRunning = false;
const startedAt = Date.now();
+ const watching = watched.count > endedWatch.current;
+ // Before the first answer the last end is unknown; then the first answer is the one to compare with.
+ let since = watched.since;
const fetchOnce = async () => {
if (!hasSession()) return;
@@ -51,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
const status = json.lastSyncStatus;
const lastSyncedAt = json.lastSyncedAt;
- setState({status, lastSyncedAt, error: null});
-
+ lastSyncedAtRef.current = lastSyncedAt;
const isRunning = status === 'RUNNING';
- const wasRunning = previousRunningRef.current;
-
- if (wasRunning === true && !isRunning) {
- onCompleteRef.current?.();
- }
- previousRunningRef.current = isRunning;
-
const elapsed = Date.now() - startedAt;
- const waitingForInitial =
- expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
- const stillWaiting = isRunning || waitingForInitial;
+ const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
+ setState({status, lastSyncedAt, error: null, stale});
- if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) {
+ if (since === undefined) since = lastSyncedAt;
+ const ended = !isRunning && (wasRunning || (watching && lastSyncedAt !== since));
+ if (ended) {
+ endedWatch.current = watched.count;
+ onCompleteRef.current?.();
+ return;
+ }
+ wasRunning = isRunning;
+
+ const waitingForInitial = expectInitialSync && status == null;
+ const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
+ if ((isRunning && !stale) || waitingForStart) {
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
}
} catch (err) {
@@ -82,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
cancelled = true;
if (timer) clearTimeout(timer);
};
- }, [enabled, expectInitialSync]);
+ }, [enabled, expectInitialSync, watched]);
return {
status: state.status,
lastSyncedAt: state.lastSyncedAt,
- isRunning: state.status === 'RUNNING',
+ isRunning: state.status === 'RUNNING' && !state.stale,
+ stale: state.stale,
error: state.error,
+ watch,
};
}
diff --git a/src/hooks/useLibrarySync.test.jsx b/src/hooks/useLibrarySync.test.jsx
new file mode 100644
index 0000000..4b8c2fe
--- /dev/null
+++ b/src/hooks/useLibrarySync.test.jsx
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, renderHook} from '@testing-library/react';
+import useLibrarySync from './useLibrarySync.js';
+import {fetchRoutes, json} from '../test/fetchRoutes.js';
+
+const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt});
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ localStorage.setItem('tbp_session', '1');
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ localStorage.clear();
+});
+
+describe('useLibrarySync', () => {
+ it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => {
+ const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
+ const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
+ await act(() => vi.advanceTimersByTimeAsync(0));
+ expect(result.current.isRunning).toBe(true);
+
+ await act(() => vi.advanceTimersByTimeAsync(125_000));
+
+ expect(result.current.isRunning).toBe(false);
+ expect(result.current.stale).toBe(true);
+ const calls = fetch.mock.calls.length;
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+ expect(fetch.mock.calls.length).toBe(calls);
+ });
+
+ it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
+ let answer = status('OK', '2026-09-30T08:00:00Z');
+ fetchRoutes({'GET /api/library/sync-status': () => answer});
+ const onComplete = vi.fn();
+ const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
+ await act(() => vi.advanceTimersByTimeAsync(0));
+
+ act(() => result.current.watch());
+ await act(() => vi.advanceTimersByTimeAsync(0));
+ // The sync was so fast that the next poll already sees it done.
+ answer = status('OK', '2026-09-30T08:05:00Z');
+ await act(() => vi.advanceTimersByTimeAsync(3_000));
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports the end of a running sync once', async () => {
+ let answer = status('RUNNING', '2026-09-30T08:00:00Z');
+ fetchRoutes({'GET /api/library/sync-status': () => answer});
+ const onComplete = vi.fn();
+ const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
+ await act(() => vi.advanceTimersByTimeAsync(0));
+
+ answer = status('FAILED', '2026-09-30T08:01:00Z');
+ await act(() => vi.advanceTimersByTimeAsync(3_000));
+ await act(() => vi.advanceTimersByTimeAsync(30_000));
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ expect(result.current.status).toBe('FAILED');
+ });
+
+ it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => {
+ let answer = status('OK', '2026-09-30T08:00:00Z');
+ fetchRoutes({'GET /api/library/sync-status': () => answer});
+ const onComplete = vi.fn();
+ const {result, rerender} = renderHook(
+ ({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}),
+ {initialProps: {expectInitialSync: false}});
+ await act(() => vi.advanceTimersByTimeAsync(0));
+
+ act(() => result.current.watch());
+ await act(() => vi.advanceTimersByTimeAsync(0));
+ answer = status('OK', '2026-09-30T08:05:00Z');
+ await act(() => vi.advanceTimersByTimeAsync(3_000));
+ expect(onComplete).toHaveBeenCalledTimes(1);
+
+ // onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows.
+ rerender({expectInitialSync: true});
+ await act(() => vi.advanceTimersByTimeAsync(0));
+ rerender({expectInitialSync: false});
+ await act(() => vi.advanceTimersByTimeAsync(0));
+
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/hooks/useUnsavedChanges.js b/src/hooks/useUnsavedChanges.js
new file mode 100644
index 0000000..f7282c0
--- /dev/null
+++ b/src/hooks/useUnsavedChanges.js
@@ -0,0 +1,58 @@
+import {useEffect} from 'react';
+import {useBlocker} from 'react-router-dom';
+import {useTranslation} from 'react-i18next';
+import {hasSession} from '../utils/auth.js';
+
+// One entry per component with unsaved edits. A router allows only one blocker, and GameDetail shows two such cards.
+const owners = new Set();
+let discardNext = false;
+
+/**
+ * The next navigation drops the unsaved edits without asking, e.g. after the game itself was removed. Only that one:
+ * a flag in the location state would also skip the question on a later "back" to that entry.
+ */
+export const discardChanges = () => {
+ discardNext = true;
+};
+
+/**
+ * Marks the calling component as having unsaved edits while `dirty`. UnsavedChangesGuard then asks before a
+ * navigation drops them.
+ */
+export default function useUnsavedChanges(dirty) {
+ useEffect(() => {
+ if (!dirty) return undefined;
+ const owner = {};
+ owners.add(owner);
+ return () => owners.delete(owner);
+ }, [dirty]);
+}
+
+/**
+ * Rendered once, inside a data router. Asks with the browser's own confirm; a reload or a closed tab gets the
+ * browser's own warning. Without a session nothing is held back, so the login page always opens.
+ */
+export function UnsavedChangesGuard() {
+ const {t} = useTranslation();
+ const blocker = useBlocker(() => {
+ const discard = discardNext;
+ discardNext = false;
+ return owners.size > 0 && hasSession() && !discard;
+ });
+
+ useEffect(() => {
+ if (blocker.state !== 'blocked') return;
+ if (window.confirm(t('UNSAVED_CHANGES.CONFIRM'))) blocker.proceed();
+ else blocker.reset();
+ }, [blocker, t]);
+
+ useEffect(() => {
+ const warn = (event) => {
+ if (owners.size > 0) event.preventDefault();
+ };
+ window.addEventListener('beforeunload', warn);
+ return () => window.removeEventListener('beforeunload', warn);
+ }, []);
+
+ return null;
+}
diff --git a/src/hooks/useUnsavedChanges.test.jsx b/src/hooks/useUnsavedChanges.test.jsx
new file mode 100644
index 0000000..7fa8e27
--- /dev/null
+++ b/src/hooks/useUnsavedChanges.test.jsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
+import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
+import {createMemoryRouter, Link, Outlet, RouterProvider, useNavigate} from 'react-router-dom';
+import useUnsavedChanges, {discardChanges, UnsavedChangesGuard} from './useUnsavedChanges.js';
+import {expireSession, markSession} from '../utils/auth.js';
+
+vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
+
+function Editor({dirty = true}) {
+ useUnsavedChanges(dirty);
+ const navigate = useNavigate();
+ return (
+ <>
+
editor
+
leave
+
+ >
+ );
+}
+
+function renderEditor(dirty) {
+ const router = createMemoryRouter([{
+ element: <>
>,
+ children: [
+ {path: '/edit', element:
},
+ {path: '/other', element: other page
},
+ {path: '/login', element: login
},
+ ],
+ }], {initialEntries: ['/edit']});
+ render();
+ return router;
+}
+
+beforeEach(() => {
+ markSession();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ localStorage.clear();
+});
+
+describe('useUnsavedChanges', () => {
+ it('asks before a link leaves unsaved edits, and stays when the user says no', async () => {
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ renderEditor(true);
+
+ fireEvent.click(screen.getByText('leave'));
+
+ expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
+ expect(await screen.findByText('editor')).toBeTruthy();
+ });
+
+ it('leaves when the user says yes', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true);
+ renderEditor(true);
+
+ fireEvent.click(screen.getByText('leave'));
+
+ expect(await screen.findByText('other page')).toBeTruthy();
+ });
+
+ it('does not ask without unsaved edits, or when the page drops them on purpose', async () => {
+ const confirm = vi.spyOn(window, 'confirm');
+ renderEditor(false);
+ fireEvent.click(screen.getByText('leave'));
+ expect(await screen.findByText('other page')).toBeTruthy();
+ cleanup();
+
+ renderEditor(true);
+ fireEvent.click(screen.getByText('remove'));
+ expect(await screen.findByText('other page')).toBeTruthy();
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it('never holds back the login page when the session ended', async () => {
+ const confirm = vi.spyOn(window, 'confirm');
+ const router = renderEditor(true);
+
+ act(() => expireSession());
+ await act(() => router.navigate('/login'));
+
+ expect(screen.getByText('login')).toBeTruthy();
+ expect(confirm).not.toHaveBeenCalled();
+ });
+
+ it('lets the browser warn before a reload or close only while there are unsaved edits', () => {
+ renderEditor(true);
+ const warned = new Event('beforeunload', {cancelable: true});
+ window.dispatchEvent(warned);
+ expect(warned.defaultPrevented).toBe(true);
+ cleanup();
+
+ renderEditor(false);
+ const free = new Event('beforeunload', {cancelable: true});
+ window.dispatchEvent(free);
+ expect(free.defaultPrevented).toBe(false);
+ });
+
+ it('asks before going back to an entry that once dropped changes on purpose', async () => {
+ const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
+ const router = renderEditor(true);
+
+ fireEvent.click(screen.getByText('remove'));
+ await screen.findByText('other page');
+ await act(() => router.navigate('/edit'));
+ await screen.findByText('editor');
+ await act(() => router.navigate(-1));
+
+ expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
+ expect(router.state.location.pathname).toBe('/edit');
+ });
+});
diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx
index 6f351cd..e24e5f2 100644
--- a/src/pages/AdminUsersPage.jsx
+++ b/src/pages/AdminUsersPage.jsx
@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useRef, useState} from 'react';
+import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
import {toast} from 'sonner';
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
+import useAbortPrevious from '../hooks/useAbortPrevious.js';
const PAGE_SIZE = 20;
const REFUSAL_KEY = {
@@ -44,13 +45,13 @@ export default function AdminUsersPage() {
const [typedEmail, setTypedEmail] = useState('');
const [busy, setBusy] = useState(false);
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
- const loadCount = useRef(0);
+ const nextSignal = useAbortPrevious();
const load = useCallback(async () => {
- const current = ++loadCount.current;
+ const signal = nextSignal();
try {
- const response = await fetchUsers(search, page, PAGE_SIZE);
- if (current !== loadCount.current) return;
+ const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
+ if (signal.aborted) return;
if (response.status === 401) return;
if (response.status === 403) {
setStatus('forbidden');
@@ -61,14 +62,15 @@ export default function AdminUsersPage() {
return;
}
const data = await response.json();
- if (current !== loadCount.current) return;
+ if (signal.aborted) return;
setResult(data);
setStatus('ready');
} catch (err) {
+ if (signal.aborted) return;
console.error('Admin users error:', err);
setStatus('error');
}
- }, [search, page]);
+ }, [nextSignal, search, page]);
useEffect(() => {
load();
diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx
index af81756..008166d 100644
--- a/src/pages/Dashboard.jsx
+++ b/src/pages/Dashboard.jsx
@@ -137,7 +137,7 @@ const Dashboard = () => {
expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: reloadLibrary,
});
- const forceSync = useForceSync(reloadLibrary);
+ const forceSync = useForceSync(sync.watch);
if (isLoading) {
return ;
@@ -227,6 +227,7 @@ const Dashboard = () => {
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
+ stale={sync.stale}
/>