diff --git a/src/components/import/ImportView.jsx b/src/components/import/ImportView.jsx index 6bfe4b0..312359e 100644 --- a/src/components/import/ImportView.jsx +++ b/src/components/import/ImportView.jsx @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react'; +import {useState} from 'react'; import {useTranslation} from 'react-i18next'; import {toast} from 'sonner'; import {CheckCircle2, Circle, Loader2, RotateCcw, XCircle} from 'lucide-react'; @@ -8,82 +8,16 @@ import {Card} from '@/components/ui/card'; import {cn} from '@/lib/utils'; import ImportReview from './ImportReview.jsx'; import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx'; -import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js'; +import useImportPolling from '../../hooks/useImportPolling.js'; +import {retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js'; -const POLL_MS = 2000; -const MAX_BACKOFF_MS = 30_000; -const MAX_FAILURES = 10; - -// Polls while a worker has the import. export default function ImportView({id, onDone, onChanged}) { const {t} = useTranslation(); - const [data, setData] = useState(null); - const [failed, setFailed] = useState(false); - // Grows after a save or a retry: the import is back with a worker, so polling starts again. - const [version, setVersion] = useState(0); - const reload = () => setVersion((current) => current + 1); - const lastStatus = useRef(null); + const {data, failed, reload} = useImportPolling(id, onChanged); // undefined while loading: the review preselects with it, so it waits. const {user} = useCurrentUserContext(); const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null; - useEffect(() => { - let timer; - let cancelled = false; - let failures = 0; - let paused = false; - // A hidden tab waits for the visibilitychange instead of polling. - const poll = () => { - if (document.hidden) paused = true; - else load(); - }; - const onVisible = () => { - if (!document.hidden && paused) { - paused = false; - load(); - } - }; - // A 5xx while Core restarts or a network error is asked again, each time twice as late. - const retry = () => { - if (++failures >= MAX_FAILURES) setFailed(true); - else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS)); - }; - const load = async () => { - try { - const response = await getImport(id); - if (cancelled) return; - if (response.status === 401) return; - // A 4xx ends the polling. - if (response.status >= 400 && response.status < 500) { - setFailed(true); - return; - } - if (!response.ok) { - retry(); - return; - } - const next = await response.json(); - if (cancelled) return; - failures = 0; - // A new status also shows in the history. - if (lastStatus.current && lastStatus.current !== next.status) onChanged(); - lastStatus.current = next.status; - setData(next); - if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS); - } catch (err) { - console.error('Import load error:', err); - if (!cancelled) retry(); - } - }; - document.addEventListener('visibilitychange', onVisible); - load(); - return () => { - cancelled = true; - clearTimeout(timer); - document.removeEventListener('visibilitychange', onVisible); - }; - }, [id, version, onChanged]); - if (failed) return

{t('IMPORT.LOAD_FAILED')}

; if (!data || favorite === undefined) return ; return ( diff --git a/src/hooks/useImportPolling.js b/src/hooks/useImportPolling.js new file mode 100644 index 0000000..311d189 --- /dev/null +++ b/src/hooks/useImportPolling.js @@ -0,0 +1,77 @@ +import {useEffect, useRef, useState} from 'react'; +import {getImport, RUNNING} from '../utils/imports.js'; + +const POLL_MS = 2000; +const MAX_BACKOFF_MS = 30_000; +const MAX_FAILURES = 10; + +/** + * Polls one import while a worker has it. `onChanged` runs when the status changes. + */ +export default function useImportPolling(id, onChanged) { + const [data, setData] = useState(null); + const [failed, setFailed] = useState(false); + // Grows after a save or a retry: the import is back with a worker, so polling starts again. + const [version, setVersion] = useState(0); + const reload = () => setVersion((current) => current + 1); + const lastStatus = useRef(null); + + useEffect(() => { + let timer; + let cancelled = false; + let failures = 0; + let paused = false; + // A hidden tab waits for the visibilitychange instead of polling. + const poll = () => { + if (document.hidden) paused = true; + else load(); + }; + const onVisible = () => { + if (!document.hidden && paused) { + paused = false; + load(); + } + }; + // A 5xx while Core restarts or a network error is asked again, each time twice as late. + const retry = () => { + if (++failures >= MAX_FAILURES) setFailed(true); + else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS)); + }; + const load = async () => { + try { + const response = await getImport(id); + if (cancelled) return; + if (response.status === 401) return; + // A 4xx ends the polling. + if (response.status >= 400 && response.status < 500) { + setFailed(true); + return; + } + if (!response.ok) { + retry(); + return; + } + const next = await response.json(); + if (cancelled) return; + failures = 0; + // A new status also shows in the history. + if (lastStatus.current && lastStatus.current !== next.status) onChanged(); + lastStatus.current = next.status; + setData(next); + if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS); + } catch (err) { + console.error('Import load error:', err); + if (!cancelled) retry(); + } + }; + document.addEventListener('visibilitychange', onVisible); + load(); + return () => { + cancelled = true; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [id, version, onChanged]); + + return {data, failed, reload}; +} diff --git a/src/hooks/useImportPolling.test.jsx b/src/hooks/useImportPolling.test.jsx new file mode 100644 index 0000000..cbe2949 --- /dev/null +++ b/src/hooks/useImportPolling.test.jsx @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {act, cleanup, renderHook} from '@testing-library/react'; +import useImportPolling from './useImportPolling.js'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +const ROUTE = 'GET /api/library/imports/7'; +// A stable callback, as ImportPage passes: a new one per render would restart the polling. +const noop = () => {}; +const tick = (ms) => act(() => vi.advanceTimersByTimeAsync(ms)); + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe('useImportPolling', () => { + it('polls every 2000 ms while the status is running', async () => { + const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING', matched: 1})}); + const {result} = renderHook(() => useImportPolling(7, noop)); + await tick(0); + expect(fetch).toHaveBeenCalledTimes(1); + expect(result.current.data.matched).toBe(1); + + await tick(2000); + expect(fetch).toHaveBeenCalledTimes(2); + await tick(2000); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it('stops after DONE and reports the change once', async () => { + let answer = json(200, {status: 'MATCHING'}); + const fetch = fetchRoutes({[ROUTE]: () => answer}); + const onChanged = vi.fn(); + const {result} = renderHook(() => useImportPolling(7, onChanged)); + await tick(0); + answer = json(200, {status: 'DONE'}); + await tick(2000); + const calls = fetch.mock.calls.length; + + await tick(30_000); + + expect(fetch.mock.calls.length).toBe(calls); + expect(result.current.data.status).toBe('DONE'); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it('backs off on 503 and gives up after 10 failures', async () => { + const fetch = fetchRoutes({[ROUTE]: json(503, {})}); + const {result} = renderHook(() => useImportPolling(7, noop)); + await tick(0); + expect(fetch).toHaveBeenCalledTimes(1); + + await tick(3999); + expect(fetch).toHaveBeenCalledTimes(1); + await tick(1); + expect(fetch).toHaveBeenCalledTimes(2); + await tick(7999); + expect(fetch).toHaveBeenCalledTimes(2); + await tick(1); + expect(fetch).toHaveBeenCalledTimes(3); + + await tick(300_000); + expect(fetch).toHaveBeenCalledTimes(10); + expect(result.current.failed).toBe(true); + }); + + it('does not call again after unmount with a timer pending', async () => { + const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})}); + const {unmount} = renderHook(() => useImportPolling(7, noop)); + await tick(0); + + unmount(); + await tick(10_000); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('does not call again after unmount with a request pending', async () => { + let release; + const fetch = fetchRoutes({[ROUTE]: () => new Promise((resolve) => { release = resolve; })}); + const {unmount} = renderHook(() => useImportPolling(7, noop)); + await tick(0); + + unmount(); + release(json(200, {status: 'MATCHING'})); + await tick(10_000); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('reload() restarts the loop: one poll per 2000 ms, not two', async () => { + const fetch = fetchRoutes({[ROUTE]: json(200, {status: 'MATCHING'})}); + const {result} = renderHook(() => useImportPolling(7, noop)); + await tick(0); + + act(() => result.current.reload()); + await tick(0); + const calls = fetch.mock.calls.length; + await tick(2000); + + expect(fetch.mock.calls.length).toBe(calls + 1); + }); +}); diff --git a/src/hooks/useSettingsLoad.js b/src/hooks/useSettingsLoad.js new file mode 100644 index 0000000..dccba86 --- /dev/null +++ b/src/hooks/useSettingsLoad.js @@ -0,0 +1,39 @@ +import {useEffect, useState} from 'react'; +import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; +import {refreshSession} from '../utils/api.js'; +import {markSession} from '../utils/auth.js'; + +/** + * Loads the profile for the settings page. After a Steam link (`steamOutcome === 'linked'`) the session is refreshed + * first. `loadError` is a translation key. + */ +export default function useSettingsLoad(steamOutcome) { + const {reload: reloadUser} = useCurrentUserContext(); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + // From this load: the context user can still be empty when the form mounts. + const [displayName, setDisplayName] = useState(''); + + useEffect(() => { + let cancelled = false; + const load = async () => { + if (steamOutcome === 'linked') { + // Refresh reads the new Steam ID from the database, so Core gets it in the token at once. + await refreshSession().catch(() => false); + markSession(); + } + const {ok, status, data} = await reloadUser(); + if (cancelled) return; + if (ok) setDisplayName(data.displayName ?? ''); + else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE'); + setIsLoading(false); + }; + + load(); + return () => { + cancelled = true; + }; + }, [reloadUser, steamOutcome]); + + return {isLoading, loadError, displayName}; +} diff --git a/src/hooks/useSettingsLoad.test.jsx b/src/hooks/useSettingsLoad.test.jsx new file mode 100644 index 0000000..a4bb1dc --- /dev/null +++ b/src/hooks/useSettingsLoad.test.jsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, renderHook, waitFor} from '@testing-library/react'; +import useSettingsLoad from './useSettingsLoad.js'; +import {CurrentUserProvider} from '../context/CurrentUserContext.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; + +const renderLoad = (steamOutcome = null) => + renderHook(() => useSettingsLoad(steamOutcome), {wrapper: CurrentUserProvider}); + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + localStorage.setItem('tbp_session', '1'); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + localStorage.clear(); +}); + +describe('useSettingsLoad', () => { + it('returns the display name when /me answers', async () => { + fetchRoutes({'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}})}); + const {result} = renderLoad(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.displayName).toBe('Lobo'); + expect(result.current.loadError).toBeNull(); + }); + + it('reports a server error', async () => { + fetchRoutes({'GET /api/users/me': json(500, {})}); + const {result} = renderLoad(); + + await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.LOAD_PROFILE')); + expect(result.current.isLoading).toBe(false); + }); + + it('reports a network failure', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('network')); + const {result} = renderLoad(); + + await waitFor(() => expect(result.current.loadError).toBe('INFO.ERROR.NETWORK_FAILURE')); + }); + + it('shows no error on 401', async () => { + fetchRoutes({'GET /api/users/me': json(401, {}), 'POST /api/auth/refresh': json(401, {})}); + const {result} = renderLoad(); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.loadError).toBeNull(); + }); + + it('refreshes the session before /me after a Steam link', async () => { + const fetch = fetchRoutes({ + 'POST /api/auth/refresh': json(204, null), + 'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}), + }); + const {result} = renderLoad('linked'); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const paths = fetch.mock.calls.map(([url]) => new URL(url, 'http://x').pathname); + expect(paths.findIndex((p) => p.endsWith('/auth/refresh'))).toBeLessThan( + paths.findIndex((p) => p.endsWith('/users/me'))); + }); + it('marksSessionAfterSteamRefreshEvenWhenUnmounted', async () => { + let answerRefresh; + fetchRoutes({ + 'POST /api/auth/refresh': () => new Promise((resolve) => { + answerRefresh = resolve; + }), + 'GET /api/users/me': json(200, {displayName: 'Lobo', settings: {}}), + }); + const {unmount} = renderLoad('linked'); + await waitFor(() => expect(answerRefresh).toBeDefined()); + + unmount(); + answerRefresh(json(204, null)); + + await waitFor(() => expect(localStorage.getItem('tbp_session')).not.toBe('1')); + }); +}); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 85e2b60..e74683e 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -13,8 +13,7 @@ import PreferencesCard from '../components/PreferencesCard.jsx'; import ProfileCard from '../components/ProfileCard.jsx'; import SteamAccountCard from '../components/SteamAccountCard.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; -import {refreshSession} from '../utils/api.js'; -import {markSession} from '../utils/auth.js'; +import useSettingsLoad from '../hooks/useSettingsLoad.js'; // The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back. const STEAM_LINK_TOASTS = { @@ -26,14 +25,11 @@ const STEAM_LINK_TOASTS = { export default function SettingsPage() { const {t} = useTranslation(); - const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext(); - const [isLoading, setIsLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - // From this page's own load: the context user can still be empty when the form mounts. - const [displayName, setDisplayName] = useState(''); + const {user: profile, updateUser: updateProfile} = useCurrentUserContext(); const [searchParams, setSearchParams] = useSearchParams(); // Read once: removing the parameter below must not load the profile again. const [steamOutcome] = useState(() => searchParams.get('steam')); + const {isLoading, loadError, displayName} = useSettingsLoad(steamOutcome); // A language change gives a new `t` and runs the effect again; the toast shows once. const steamToastShown = useRef(false); @@ -48,22 +44,6 @@ export default function SettingsPage() { }, {replace: true}); }, [steamOutcome, setSearchParams, t]); - useEffect(() => { - const load = async () => { - if (steamOutcome === 'linked') { - // Refresh reads the new Steam ID from the database, so Core gets it in the token at once. - await refreshSession().catch(() => false); - markSession(); - } - const {ok, status, data} = await reloadUser(); - if (ok) setDisplayName(data.displayName ?? ''); - else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE'); - setIsLoading(false); - }; - - load(); - }, [reloadUser, steamOutcome]); - if (isLoading) { return (