diff --git a/frontend/src/lib/hooks/useApi.ts b/frontend/src/lib/hooks/useApi.ts index 3c01996a..a0230eba 100644 --- a/frontend/src/lib/hooks/useApi.ts +++ b/frontend/src/lib/hooks/useApi.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; type Fetcher = (...args: Args) => Promise; @@ -52,22 +52,35 @@ export const defaultRedirectMap: Record = { export function fetcherApiCallback( fetcher: Fetcher, redirectMap: Record = defaultRedirectMap, - { initialLoading = true }: { initialLoading?: boolean } = {}, + { + loadingGracePeriodMs = 0, + }: { loadingGracePeriodMs?: number } = {}, ) { const navigate = useNavigate(); // eslint-disable-line react-hooks/rules-of-hooks const [data, setData] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks const [error, setError] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks const [errorStatus, setErrorStatus] = useState(null); // eslint-disable-line react-hooks/rules-of-hooks - const [loading, setLoading] = useState(initialLoading); // eslint-disable-line react-hooks/rules-of-hooks + const [loading, setLoading] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks + const [hasLoaded, setHasLoaded] = useState(false); // eslint-disable-line react-hooks/rules-of-hooks + const graceTimerRef = useRef | null>(null); // eslint-disable-line react-hooks/rules-of-hooks const call = useCallback( // eslint-disable-line react-hooks/rules-of-hooks async (...params: Args) => { - setLoading(true); - // reset error and status for incoming data setError(null); setErrorStatus(null); + // If grace period is set, delay showing the spinner. + // If data arrives before the timer fires, the spinner never appears. + if (loadingGracePeriodMs > 0) { + if (graceTimerRef.current) clearTimeout(graceTimerRef.current); + graceTimerRef.current = setTimeout(() => { + setLoading(true); + }, loadingGracePeriodMs); + } else { + setLoading(true); + } + try { const result = await fetcher(...params); setData(result); @@ -91,13 +104,19 @@ export function fetcherApiCallback( navigate(redirectPath); } } finally { + // Cancel grace timer if data arrived before it fired + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } // notice navigate() is non-blocking, React will still complete the current // render/update cycle unless the route changes synchronously. setLoading(false); + setHasLoaded(true); } }, - [fetcher, navigate, redirectMap], + [fetcher, navigate, redirectMap, loadingGracePeriodMs], ); - return { data, error, errorStatus, loading, call }; + return { data, error, errorStatus, loading, hasLoaded, call }; } diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index fb624748..0310b9d2 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -45,12 +45,14 @@ export default function Home() { const useBeta = searchParams.has("use_beta"); const forceRefresh = searchParams.has("force_refresh"); - const { data, loading, error, errorStatus, call } = fetcherApiCallback< - LeaderboardSummaries, - [boolean, boolean] - >(fetchLeaderboardSummaries, undefined, { - initialLoading: !useBeta, - }); + const { data, loading, hasLoaded, error, errorStatus, call } = + fetcherApiCallback< + LeaderboardSummaries, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any[] + >(fetchLeaderboardSummaries, undefined, { + loadingGracePeriodMs: 200, + }); useEffect(() => { call(useBeta, forceRefresh); @@ -97,6 +99,8 @@ export default function Home() { {error ? ( + ) : !hasLoaded && !loading ? ( + null ) : loading ? ( ) : leaderboards.length > 0 ? (