fix: abort old loads, stale sync state, warn before unsaved edits are lost

This commit is contained in:
lobo committed 2026-09-30 07:51:09 +02:00
1 parent cbafa3d540
commit 9ddc50f819
28 files changed
+753 -121

No files matched your search

+2
View File
@@ -25,6 +25,7 @@ import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
import {clearSession} from './utils/auth.js';
import useSession from './hooks/useSession.js';
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
import {logout} from './utils/api.js';
function App() {
@@ -84,6 +85,7 @@ function App() {
</Link>
</footer>
</div>
<UnsavedChangesGuard/>
<Toaster richColors position="bottom-right"/>
</TooltipProvider>
);
+2
View File
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx';
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
const toForm = (entry) => ({
status: entry.status ?? 'BACKLOG',
@@ -33,6 +34,7 @@ export default function BacklogCard({game, onSaved}) {
const [busy, setBusy] = useState(false);
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
useUnsavedChanges(dirty);
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => {
+4
View File
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
@@ -26,6 +27,7 @@ export default function ManualGameCard({game, onSaved}) {
const playtimeMinutes = hoursToMinutes(hours);
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
useUnsavedChanges(dirty);
const run = async (request, onOk) => {
setBusy(true);
@@ -52,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) {
const remove = () => run(() => removeGame(game.appid), () => {
toast.success(t('MANUAL_GAME.REMOVED'));
// The game is gone, so are its unsaved edits on this page.
discardChanges();
navigate('/library', {replace: true});
});
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import ManualGameCard from './ManualGameCard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: -1025, platform: 'PS5', playtimeMinutes: 90};
const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
const renderCard = (onSaved = vi.fn()) => renderWithProviders(<ManualGameCard game={game} onSaved={onSaved}/>,
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('ManualGameCard', () => {
it('saves the trimmed platform and the minutes, then reports them', async () => {
answer({'PUT /api/library/games/-1025/manual': json(200, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}});
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120}));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/-1025/manual');
expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120});
});
it('keeps the save button disabled while nothing changed', () => {
answer({});
renderCard();
expect(saveButton().disabled).toBe(true);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}});
expect(saveButton().disabled).toBe(true);
});
it('removes the game only after a confirm and opens the library', async () => {
answer({'DELETE /api/library/games/-1025': json(204, null)});
const {router} = renderCard();
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
});
it('shows an error when the save fails', async () => {
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
fireEvent.click(saveButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(onSaved).not.toHaveBeenCalled();
});
it('shows no toast on a 401', async () => {
answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
const onSaved = vi.fn();
renderCard(onSaved);
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
fireEvent.click(saveButton());
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true));
await waitFor(() => expect(saveButton().disabled).toBe(false));
expect(toast.error).not.toHaveBeenCalled();
expect(toast.success).not.toHaveBeenCalled();
expect(onSaved).not.toHaveBeenCalled();
});
});
+11 -2
View File
@@ -1,5 +1,5 @@
import {useTranslation} from 'react-i18next';
import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react';
import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react';
const formatRelative = (iso, locale) => {
if (!iso) return null;
@@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => {
return rtf.format(-Math.round(diffSec / 86400), 'day');
};
export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
const {t, i18n} = useTranslation();
if (isRunning) {
@@ -24,6 +24,15 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
);
}
if (stale) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="size-4"/>
<span>{t('LIBRARY.SYNC_STALE')}</span>
</div>
);
}
if (status === 'FAILED') {
return (
<div className="flex items-center gap-2 text-sm text-destructive">
+17
View File
@@ -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;
}, []);
}
+54
View File
@@ -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();
});
});
+3 -5
View File
@@ -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'));
+66
View File
@@ -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');
});
});
+40 -22
View File
@@ -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,
};
}
+91
View File
@@ -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);
});
});
+58
View File
@@ -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;
}
+118
View File
@@ -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 (
<>
<p>editor</p>
<Link to="/other">leave</Link>
<button onClick={() => {
discardChanges();
navigate('/other');
}}>remove</button>
</>
);
}
function renderEditor(dirty) {
const router = createMemoryRouter([{
element: <><UnsavedChangesGuard/><Outlet/></>,
children: [
{path: '/edit', element: <Editor dirty={dirty}/>},
{path: '/other', element: <p>other page</p>},
{path: '/login', element: <p>login</p>},
],
}], {initialEntries: ['/edit']});
render(<RouterProvider router={router}/>);
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');
});
});
+9 -7
View File
@@ -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();
+2 -1
View File
@@ -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 <DashboardSkeleton/>;
@@ -227,6 +227,7 @@ const Dashboard = () => {
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
stale={sync.stale}
/>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
+13 -33
View File
@@ -26,7 +26,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx';
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
import {apiFetch} from '../utils/api.js';
import {request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {canCorrect} from '../utils/corrections.js';
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
@@ -94,43 +95,22 @@ export default function GameDetail() {
setData(null);
}
const nextSignal = useAbortPrevious();
useEffect(() => {
let cancelled = false;
const signal = nextSignal();
setIsLoading(true);
setError(null);
(async () => {
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
const response = await apiFetch(url, {
headers: {
'Content-Type': 'application/json',
},
});
if (cancelled) return;
if (response.status === 401) return;
if (response.status === 404) {
setError('GAME_DETAIL.NOT_FOUND');
return;
}
if (!response.ok) {
setError('GAME_DETAIL.ERROR_LOAD');
return;
}
const json = await response.json();
setData(json);
} catch (err) {
if (!cancelled) {
console.error('GameDetail load error:', err);
setError('INFO.ERROR.NETWORK_FAILURE');
}
} finally {
if (!cancelled) setIsLoading(false);
}
const {ok, status, data: json} = await request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
if (signal.aborted) return;
setIsLoading(false);
if (status === 401) return;
if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
else setData(json);
})();
return () => {
cancelled = true;
};
}, [appid, reloads]);
}, [nextSignal, appid, reloads]);
const sortedAchievements = useMemo(() => {
if (!data?.achievements) return [];
+18
View File
@@ -369,3 +369,21 @@ describe('GameDetail confetti', () => {
expect(await opened({status: 'PLAYING'})).toBeNull();
});
});
describe('GameDetail session', () => {
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
toast.error.mockClear();
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games/220': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220'],
guarded: true, routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library/220'});
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
});
});
+16 -25
View File
@@ -20,7 +20,8 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {apiFetch, request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
import {platformLabel} from '../utils/platforms.js';
const PAGE_SIZES = [20, 40, 80];
@@ -65,33 +66,22 @@ export default function GameLibrary() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const nextSignal = useAbortPrevious();
const load = useCallback(async () => {
const signal = nextSignal();
setIsLoading(true);
setError(null);
try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
const response = await apiFetch(url, {
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 401) return;
if (!response.ok) {
setError('LIBRARY.ERROR_LOAD');
return;
}
const json = await response.json();
setData(json);
} catch (err) {
console.error('Library load error:', err);
setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return;
setIsLoading(false);
if (status === 401) return;
if (!ok || !json) {
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
return;
}
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
useEffect(() => {
load();
@@ -156,7 +146,7 @@ export default function GameLibrary() {
updateParams({page: target});
};
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
const syncing = syncBusy || sync.isRunning;
const ready = !isLoading && !error;
@@ -174,6 +164,7 @@ export default function GameLibrary() {
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
stale={sync.stale}
/>
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
+51
View File
@@ -6,8 +6,11 @@ import GameLibrary from './GameLibrary.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
@@ -70,6 +73,36 @@ describe('GameLibrary search', () => {
expect(screen.queryByText(/\/5$/)).toBeNull();
});
it('shows the newest search when an older answer comes last', async () => {
const later = [];
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
'GET /api/library/games': (url, init) => {
const q = new URL(url, 'http://x').searchParams.get('q');
if (!q) return games('Half-Life 2');
// The first search answers only after the second one.
return new Promise((resolve, reject) => {
later.push({q, answer: () => resolve(games(q === 'ha' ? 'Old answer' : 'New answer'))});
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
},
});
renderLibrary();
await screen.findByText('Half-Life 2');
typeInto('ha');
await waitFor(() => expect(later).toHaveLength(1));
typeInto('half');
await waitFor(() => expect(later).toHaveLength(2));
await act(async () => later[1].answer());
await act(async () => later[0].answer());
expect(screen.getByText('New answer')).toBeTruthy();
expect(screen.queryByText('Old answer')).toBeNull();
});
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
const lib = renderLibrary('/library?page=3');
@@ -216,3 +249,21 @@ describe('GameLibrary with games added by hand', () => {
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
});
});
describe('GameLibrary session', () => {
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(401, {}),
'GET /api/library/games-count': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(<GameLibrary/>, {path: '/library', entries: ['/library?q=zelda'],
guarded: true, routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=zelda'});
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
});
});
+4 -1
View File
@@ -20,6 +20,7 @@ import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limi
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
import {preselectPlatform} from '../utils/platforms.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
const POLL_MS = 2000;
// Statuses a worker still has: the page polls while it shows one.
@@ -341,7 +342,9 @@ export const initialChoice = (row, favorite) => {
function Review({data, favorite, onSaved, onDone}) {
const {t} = useTranslation();
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
const [choices, setChoices] = useState(initialChoices);
useUnsavedChanges(choices !== initialChoices);
const [busy, setBusy] = useState(false);
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
+17
View File
@@ -7,6 +7,7 @@ import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -273,3 +274,19 @@ describe('review findings', () => {
});
});
describe('ImportPage session', () => {
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/imports': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
});
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: ['/import'], guarded: true,
routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
expect(toast.error).not.toHaveBeenCalled();
});
});
+17 -23
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -12,7 +12,8 @@ import GameCover from '../components/GameCover.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {apiFetch} from '../utils/api.js';
import {request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
const BUDGETS = [
@@ -45,33 +46,26 @@ export default function PlayNextPage() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
// A slow answer for an older budget or mood must not replace the newer one.
const latestRequest = useRef(0);
const nextSignal = useAbortPrevious();
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
const request = ++latestRequest.current;
const signal = nextSignal();
setLoading(true);
setError(null);
try {
const response = await apiFetch(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
if (request !== latestRequest.current) return;
if (response.status === 401) return;
if (!response.ok) {
setError('PLAY_NEXT.ERROR_LOAD');
return;
}
const picked = (await response.json()).games ?? [];
if (request !== latestRequest.current) return;
setGames(picked);
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
} catch (err) {
console.error('Picker load error:', err);
if (request === latestRequest.current) setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
if (request === latestRequest.current) setLoading(false);
const {ok, status, data} = await request(
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
if (signal.aborted) return;
setLoading(false);
if (status === 401) return;
if (!ok || !data) {
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
return;
}
const picked = data.games ?? [];
setGames(picked);
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
}, []);
}, [nextSignal]);
useEffect(() => {
load(budget.maxMinutes, mood, []);
+26
View File
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library
import SettingsPage from './SettingsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
import {toast} from 'sonner';
vi.mock('react-i18next', async () => {
const mock = await import('../test/i18n-mock.js');
@@ -239,3 +240,28 @@ describe('SettingsPage favorite platform', () => {
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
});
});
describe('SettingsPage session', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => {
cleanup();
localStorage.clear();
});
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
toast.error.mockClear();
fetchRoutes({
'GET /api/users/me': json(401, {}),
'POST /api/auth/refresh': json(401, {}),
'GET /api/version': json(404, {}),
});
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
guarded: true, routes: {'/login': <p>login</p>}});
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/settings'});
expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
});
});
+2 -2
View File
@@ -7,8 +7,8 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
/**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
*/
export const fetchUsers = (query, page, size) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`);
export const fetchUsers = (query, page, size, init) =>
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`, init);
/**
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
+1
View File
@@ -89,5 +89,6 @@ export async function request(url, init = {}) {
} catch {
// no JSON body, e.g. a 204
}
if (init.signal?.aborted) return {ok: false, status: 0, data: null};
return {ok: response.ok, status: response.status, data};
}
+10
View File
@@ -209,6 +209,16 @@ describe('request', () => {
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
});
it('gives status 0 when the request is aborted while its body is read', async () => {
const controller = new AbortController();
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => {
controller.abort();
throw new DOMException('aborted', 'AbortError');
}}));
expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null});
});
it('gives status 0 on a network error and logs it, but not an abort', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });