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

+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();
});
});