Public Access
fix: abort old loads, stale sync state, warn before unsaved edits are lost
This commit is contained in:
1 parent
cbafa3d540
commit
9ddc50f819
28 files changed
+753
-121
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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' : ''}/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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, []);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user