diff --git a/src/hooks/useGitHubData.ts b/src/hooks/useGitHubData.ts index f4c78cf6..7dfb947b 100644 --- a/src/hooks/useGitHubData.ts +++ b/src/hooks/useGitHubData.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { Octokit } from '@octokit/core'; interface GitHubItem { @@ -34,6 +34,7 @@ export const useGitHubData = ( // Prevent stale responses overwriting latest data const lastRequestId = useRef(0); + const abortControllerRef = useRef(null); const fetchPaginated = async ( octokit: Octokit, @@ -41,7 +42,8 @@ export const useGitHubData = ( type: 'issue' | 'pr', page = 1, perPage = 10, - filters: FetchFilters = {} + filters: FetchFilters = {}, + signal?: AbortSignal ) => { let q = `author:${username} is:${type}`; @@ -77,6 +79,9 @@ export const useGitHubData = ( order: 'desc', per_page: perPage, page, + request: { + signal, + }, } ); @@ -100,6 +105,14 @@ export const useGitHubData = ( return; } + // Cancel any active in-flight request before triggering a new one + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++lastRequestId.current; setLoading(true); @@ -122,7 +135,8 @@ export const useGitHubData = ( 'issue', page, perPage, - filters + filters, + controller.signal ) ); } @@ -135,15 +149,16 @@ export const useGitHubData = ( 'pr', page, perPage, - filters + filters, + controller.signal ) ); } const results = await Promise.allSettled(requests); - // Ignore stale requests - if (requestId !== lastRequestId.current) { + // Ignore stale or aborted requests + if (requestId !== lastRequestId.current || abortControllerRef.current !== controller) { return; } @@ -156,6 +171,10 @@ export const useGitHubData = ( setIssues(issueResult.value.items); setTotalIssues(issueResult.value.total); } else { + const reason = issueResult.reason; + if (reason && reason.name === 'AbortError') { + return; + } setIssues([]); setTotalIssues(0); } @@ -170,6 +189,10 @@ export const useGitHubData = ( setPrs(prResult.value.items); setTotalPrs(prResult.value.total); } else { + const reason = prResult.reason; + if (reason && reason.name === 'AbortError') { + return; + } setPrs([]); setTotalPrs(0); } @@ -180,6 +203,12 @@ export const useGitHubData = ( ); if (hasRejected) { + const wasAborted = results.some( + (result) => result.status === 'rejected' && result.reason?.name === 'AbortError' + ); + if (wasAborted) { + return; + } setError( 'Some GitHub data could not be fetched completely.' ); @@ -187,15 +216,20 @@ export const useGitHubData = ( setRateLimited(false); } catch (err: unknown) { - if (requestId !== lastRequestId.current) { + if (requestId !== lastRequestId.current || abortControllerRef.current !== controller) { return; } const error = err as { status?: number; message?: string; + name?: string; }; + if (error.name === 'AbortError') { + return; + } + const errorMessage = error.message?.toLowerCase() || ''; @@ -231,7 +265,7 @@ export const useGitHubData = ( ); } } finally { - if (requestId === lastRequestId.current) { + if (requestId === lastRequestId.current && abortControllerRef.current === controller) { setLoading(false); } } @@ -239,6 +273,15 @@ export const useGitHubData = ( [getOctokit, rateLimited] ); + // Cleanup abort controller on component unmount + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + return { issues, prs, diff --git a/src/pages/Tracker/Tracker.tsx b/src/pages/Tracker/Tracker.tsx index 82143050..04df12d7 100644 --- a/src/pages/Tracker/Tracker.tsx +++ b/src/pages/Tracker/Tracker.tsx @@ -81,29 +81,35 @@ const Home: React.FC = () => { const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // Prefill from user context on mount - useEffect(() => { - const user = userContext?.user; - if (user?.username) setUsername(user.username); - if (user?.token) setToken(user.token); - }, []); - - const debouncedUsername = useDebounce(username, 800); + const [debouncedUsername, setDebouncedUsername] = useState(username); - // Auto-fetch when debounced username or token changes useEffect(() => { - if (debouncedUsername) { - setPage(0); - fetchData(debouncedUsername, 1, ROWS_PER_PAGE); + if (!username) { + setDebouncedUsername(""); + return; } - }, [debouncedUsername, token]); + const handler = setTimeout(() => { + setDebouncedUsername(username); + }, 500); + + return () => { + clearTimeout(handler); + }; + }, [username]); - // Fetch when tab or page changes (username already set) + // Fetch data when debouncedUsername, tab, or page changes useEffect(() => { - if (username) { - fetchData(username, page + 1, ROWS_PER_PAGE); + if (debouncedUsername && debouncedUsername.trim().length >= 1) { + fetchData(debouncedUsername, page + 1, ROWS_PER_PAGE); } - }, [tab, page]); + }, [debouncedUsername, tab, page]); + + const handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + setPage(0); + setDebouncedUsername(username); + fetchData(username, 1, ROWS_PER_PAGE); + }; const handlePageChange = (_: unknown, newPage: number) => {