-
Notifications
You must be signed in to change notification settings - Fork 146
Improve search stability: debounce input and cancel in-flight GitHub requests #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0c81fda
f5e292a
8546082
3b33d5c
c4d017d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,10 @@ | ||||||||||||||||||||||
| import { useState, useCallback, useRef, useEffect } from 'react'; | ||||||||||||||||||||||
| import { classifyCommit } from '../utils/commitClassifier'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| export const useGitHubData = (getOctokit: () => any) => { | ||||||||||||||||||||||
| const [issues, setIssues] = useState<any[]>([]); | ||||||||||||||||||||||
| const [prs, setPrs] = useState<any[]>([]); | ||||||||||||||||||||||
| const [commits, setCommits] = useState<any[]>([]); | ||||||||||||||||||||||
| import { useState, useCallback, useRef } from 'react'; | ||||||||||||||||||||||
| import { Octokit } from '@octokit/core'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -30,8 +37,19 @@ export const useGitHubData = ( | |||||||||||||||||||||
| const [error, setError] = useState(''); | ||||||||||||||||||||||
| const [totalIssues, setTotalIssues] = useState(0); | ||||||||||||||||||||||
| const [totalPrs, setTotalPrs] = useState(0); | ||||||||||||||||||||||
| const [totalCommits, setTotalCommits] = useState(0); | ||||||||||||||||||||||
| const [rateLimited, setRateLimited] = useState(false); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10, signal?: AbortSignal) => { | ||||||||||||||||||||||
| const q = `author:${username} is:${type}`; | ||||||||||||||||||||||
| const response = await octokit.request('GET /search/issues', { | ||||||||||||||||||||||
| q, | ||||||||||||||||||||||
| sort: 'created', | ||||||||||||||||||||||
| order: 'desc', | ||||||||||||||||||||||
| per_page, | ||||||||||||||||||||||
| page, | ||||||||||||||||||||||
| request: signal ? { signal } : undefined, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| // Prevent stale responses overwriting latest data | ||||||||||||||||||||||
| const lastRequestId = useRef(0); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -86,7 +104,109 @@ export const useGitHubData = ( | |||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const fetchCommitsPaginated = async (octokit: any, username: string, page = 1, per_page = 10, signal?: AbortSignal) => { | ||||||||||||||||||||||
| const q = `author:${username}`; | ||||||||||||||||||||||
| const response = await octokit.request('GET /search/commits', { | ||||||||||||||||||||||
| q, | ||||||||||||||||||||||
| sort: 'author-date', | ||||||||||||||||||||||
| order: 'desc', | ||||||||||||||||||||||
| per_page, | ||||||||||||||||||||||
| page, | ||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||
| accept: 'application/vnd.github.cloak-preview+json', | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| request: signal ? { signal } : undefined, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const items = response.data.items.map((item: any) => ({ | ||||||||||||||||||||||
| ...item, | ||||||||||||||||||||||
| created_at: item.commit.author?.date || item.commit.committer?.date, | ||||||||||||||||||||||
| classifiedInfo: classifyCommit(item.commit.message), | ||||||||||||||||||||||
| })); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| items, | ||||||||||||||||||||||
| total: response.data.total_count, | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // refs for debounce timer and abort controller | ||||||||||||||||||||||
| const debounceRef = useRef<number | null>(null); | ||||||||||||||||||||||
| const abortControllerRef = useRef<AbortController | null>(null); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const fetchData = useCallback( | ||||||||||||||||||||||
| (username: string, page = 1, perPage = 10) => { | ||||||||||||||||||||||
| const DEBOUNCE_MS = 350; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const octokit = getOctokit(); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| if (!octokit) return; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // debounce: clear existing timer | ||||||||||||||||||||||
| if (debounceRef.current) { | ||||||||||||||||||||||
| clearTimeout(debounceRef.current); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| debounceRef.current = window.setTimeout(async () => { | ||||||||||||||||||||||
| // cancel previous in-flight requests | ||||||||||||||||||||||
| if (abortControllerRef.current) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| abortControllerRef.current.abort(); | ||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||
| // ignore | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const controller = new AbortController(); | ||||||||||||||||||||||
| abortControllerRef.current = controller; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setLoading(true); | ||||||||||||||||||||||
| setError(''); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| const [issueRes, prRes, commitRes] = await Promise.all([ | ||||||||||||||||||||||
| fetchPaginated(octokit, username, 'issue', page, perPage, controller.signal), | ||||||||||||||||||||||
| fetchPaginated(octokit, username, 'pr', page, perPage, controller.signal), | ||||||||||||||||||||||
| fetchCommitsPaginated(octokit, username, page, perPage, controller.signal).catch((err) => { | ||||||||||||||||||||||
| if (err.name === 'AbortError') return { items: [], total: 0 }; | ||||||||||||||||||||||
| console.error('Commit fetch failed:', err); | ||||||||||||||||||||||
| return { items: [], total: 0 }; | ||||||||||||||||||||||
| }), | ||||||||||||||||||||||
| ]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| setIssues(issueRes.items); | ||||||||||||||||||||||
| setPrs(prRes.items); | ||||||||||||||||||||||
| setCommits(commitRes.items); | ||||||||||||||||||||||
| setTotalIssues(issueRes.total); | ||||||||||||||||||||||
| setTotalPrs(prRes.total); | ||||||||||||||||||||||
| setTotalCommits(commitRes.total); | ||||||||||||||||||||||
| setRateLimited(false); | ||||||||||||||||||||||
| } catch (err: any) { | ||||||||||||||||||||||
| if (err.name === 'AbortError') { | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| const errorMessage = err.message?.toLowerCase() || ""; | ||||||||||||||||||||||
| if (err.status === 403) { | ||||||||||||||||||||||
| setError('GitHub API rate limit exceeded. Please provide a PAT to continue.'); | ||||||||||||||||||||||
| setRateLimited(true); | ||||||||||||||||||||||
| } else if (errorMessage.includes("do not exist")){ | ||||||||||||||||||||||
| setError('User not found. Please check the spelling of the GitHub username.'); | ||||||||||||||||||||||
| } else if (err.status === 401 || errorMessage.includes("permission")){ | ||||||||||||||||||||||
| setError('Private repository detected. Please input PAT.'); | ||||||||||||||||||||||
| } else if(err.status===404){ | ||||||||||||||||||||||
| setError('Resource not found.'); | ||||||||||||||||||||||
| } else if (errorMessage.includes("validation failed")) { | ||||||||||||||||||||||
| setError('Invalid GitHub username or insufficient permissions.'); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| setError( | ||||||||||||||||||||||
| 'Unable to fetch GitHub data. Please verify the username, token, or network connection.' | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||
| setLoading(false); | ||||||||||||||||||||||
| if (abortControllerRef.current === controller) abortControllerRef.current = null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+205
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard Only the currently active controller should finalize loading state. Suggested fix } finally {
- setLoading(false);
- if (abortControllerRef.current === controller) abortControllerRef.current = null;
+ if (abortControllerRef.current === controller) {
+ setLoading(false);
+ abortControllerRef.current = null;
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| }, DEBOUNCE_MS); | ||||||||||||||||||||||
| async ( | ||||||||||||||||||||||
| username: string, | ||||||||||||||||||||||
| page = 1, | ||||||||||||||||||||||
|
|
@@ -239,11 +359,30 @@ export const useGitHubData = ( | |||||||||||||||||||||
| [getOctokit, rateLimited] | ||||||||||||||||||||||
| ); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // cleanup on unmount: clear debounce timer and abort any in-flight requests | ||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||
| if (debounceRef.current) { | ||||||||||||||||||||||
| clearTimeout(debounceRef.current); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if (abortControllerRef.current) { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| abortControllerRef.current.abort(); | ||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||
| // ignore | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| abortControllerRef.current = null; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return { | ||||||||||||||||||||||
| issues, | ||||||||||||||||||||||
| prs, | ||||||||||||||||||||||
| commits, | ||||||||||||||||||||||
| totalIssues, | ||||||||||||||||||||||
| totalPrs, | ||||||||||||||||||||||
| totalCommits, | ||||||||||||||||||||||
| loading, | ||||||||||||||||||||||
| error, | ||||||||||||||||||||||
| rateLimited, | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 553
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 160
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 9642
🏁 Script executed:
Repository: GitMetricsLab/github_tracker
Length of output: 8072
Fix broken module structure in
src/hooks/useGitHubData.ts(duplicate hook + stray code blocks break compilation)useGitHubDatais exported twice (starts at line 4 and again at line 31) without properly closing the first export.import { useState, useCallback, useRef } from 'react';appears inside the hook body (line 8), which is invalid syntax in TS/JS modules.fetchPaginatedis declared twice and the earlier declaration is left in an incomplete state before the later one (lines ~43-56).fetchDatacontains a stray legacyasync (...) => { ... }block starting around line 210, corrupting theuseCallbackcallback structure.Remove the leftover legacy implementation so the file contains exactly one
export const useGitHubDataand one coherentfetchPaginated/fetchDataimplementation.🧰 Tools
🪛 Biome (2.4.15)
[error] 8-8: Illegal use of an import declaration not at the top level
(parse)
🤖 Prompt for AI Agents