Skip to content

Commit 86418ac

Browse files
committed
fix: optimize GitHub search with debounce and request cancellation
1 parent 8d17610 commit 86418ac

3 files changed

Lines changed: 152 additions & 79 deletions

File tree

src/hooks/useDebounce.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useEffect, useState } from 'react';
2+
3+
/**
4+
* Custom hook for debouncing values
5+
* Delays updating a state value until after the user stops changing it for a specified duration
6+
*
7+
* @param value - The value to debounce
8+
* @param delay - The debounce delay in milliseconds (default: 300ms)
9+
* @returns The debounced value
10+
*/
11+
export const useDebounce = <T,>(value: T, delay: number = 300): T => {
12+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
13+
14+
useEffect(() => {
15+
// Set up the timeout
16+
const handler = setTimeout(() => {
17+
setDebouncedValue(value);
18+
}, delay);
19+
20+
// Clean up the timeout if value changes before delay is complete
21+
return () => clearTimeout(handler);
22+
}, [value, delay]);
23+
24+
return debouncedValue;
25+
};

src/hooks/useGitHubData.ts

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback } from 'react';
1+
import { useState, useCallback, useRef, useEffect } from 'react';
22

33
export const useGitHubData = (getOctokit: () => any) => {
44
const [issues, setIssues] = useState([]);
@@ -9,14 +9,42 @@ export const useGitHubData = (getOctokit: () => any) => {
99
const [totalPrs, setTotalPrs] = useState(0);
1010
const [rateLimited, setRateLimited] = useState(false);
1111

12-
const fetchPaginated = async (octokit: any, username: string, type: string, page = 1, per_page = 10) => {
12+
// Store AbortController to cancel in-flight requests
13+
const abortControllerRef = useRef<AbortController | null>(null);
14+
15+
// Cleanup function to cancel any pending requests
16+
const cancelPendingRequest = useCallback(() => {
17+
if (abortControllerRef.current) {
18+
abortControllerRef.current.abort();
19+
abortControllerRef.current = null;
20+
}
21+
}, []);
22+
23+
// Cleanup on component unmount
24+
useEffect(() => {
25+
return () => {
26+
cancelPendingRequest();
27+
};
28+
}, [cancelPendingRequest]);
29+
30+
const fetchPaginated = async (
31+
octokit: any,
32+
username: string,
33+
type: string,
34+
page = 1,
35+
per_page = 10,
36+
signal?: AbortSignal
37+
) => {
1338
const q = `author:${username} is:${type}`;
1439
const response = await octokit.request('GET /search/issues', {
1540
q,
1641
sort: 'created',
1742
order: 'desc',
1843
per_page,
1944
page,
45+
request: {
46+
signal, // Pass AbortSignal to the request
47+
},
2048
});
2149

2250
return {
@@ -27,41 +55,62 @@ export const useGitHubData = (getOctokit: () => any) => {
2755

2856
const fetchData = useCallback(
2957
async (username: string, page = 1, perPage = 10) => {
30-
58+
// Validate inputs
59+
if (!username || username.trim().length === 0) {
60+
setError('Please enter a GitHub username.');
61+
return;
62+
}
63+
3164
const octokit = getOctokit();
3265

33-
if (!octokit || !username) return;
66+
if (!octokit) {
67+
setError('Authentication not initialized.');
68+
return;
69+
}
70+
71+
// Cancel any existing in-flight requests
72+
cancelPendingRequest();
73+
74+
// Create new AbortController for this request
75+
abortControllerRef.current = new AbortController();
76+
const signal = abortControllerRef.current.signal;
3477

3578
setLoading(true);
3679
setError('');
3780

3881
try {
3982
const [issueRes, prRes] = await Promise.all([
40-
fetchPaginated(octokit, username, 'issue', page, perPage),
41-
fetchPaginated(octokit, username, 'pr', page, perPage),
83+
fetchPaginated(octokit, username, 'issue', page, perPage, signal),
84+
fetchPaginated(octokit, username, 'pr', page, perPage, signal),
4285
]);
4386

44-
setIssues(issueRes.items);
45-
setPrs(prRes.items);
46-
setTotalIssues(issueRes.total);
47-
setTotalPrs(prRes.total);
48-
setRateLimited(false);
87+
// Check if request was aborted before updating state
88+
if (!signal.aborted) {
89+
setIssues(issueRes.items);
90+
setPrs(prRes.items);
91+
setTotalIssues(issueRes.total);
92+
setTotalPrs(prRes.total);
93+
setRateLimited(false);
94+
}
4995
} catch (err: any) {
96+
// Don't show error if request was intentionally aborted
97+
if (err.name === 'AbortError') {
98+
return;
99+
}
100+
50101
const errorMessage = err.message?.toLowerCase() || "";
51102
if (err.status === 403) {
52103
setError('GitHub API rate limit exceeded. Please provide a PAT to continue.');
53-
setRateLimited(true);
54-
} else if (errorMessage.includes("do not exist")){
104+
setRateLimited(true);
105+
} else if (errorMessage.includes("do not exist")) {
55106
setError('User not found. Please check the spelling of the GitHub username.');
56-
} else if (err.status === 401 || errorMessage.includes("permission")){
107+
} else if (err.status === 401 || errorMessage.includes("permission")) {
57108
setError('Private repository detected. Please input PAT.');
58-
}else if(err.status===404){
109+
} else if (err.status === 404) {
59110
setError('Resource not found.');
60-
}
61-
else if (errorMessage.includes("validation failed")) {
111+
} else if (errorMessage.includes("validation failed")) {
62112
setError('Invalid GitHub username or insufficient permissions.');
63-
}
64-
else {
113+
} else {
65114
setError(
66115
'Unable to fetch GitHub data. Please verify the username, token, or network connection.'
67116
);
@@ -70,7 +119,7 @@ export const useGitHubData = (getOctokit: () => any) => {
70119
setLoading(false);
71120
}
72121
},
73-
[getOctokit]
122+
[getOctokit, cancelPendingRequest]
74123
);
75124

76125
return {

src/pages/Tracker/Tracker.tsx

Lines changed: 58 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { useTheme } from "@mui/material/styles";
3434
import { useGitHubAuth } from "../../hooks/useGitHubAuth";
3535
import { useGitHubData } from "../../hooks/useGitHubData";
36+
import { useDebounce } from "../../hooks/useDebounce";
3637

3738
const ROWS_PER_PAGE = 10;
3839

@@ -79,18 +80,22 @@ const Home: React.FC = () => {
7980
const [startDate, setStartDate] = useState("");
8081
const [endDate, setEndDate] = useState("");
8182

82-
// Fetch data when username, tab, or page changes
83+
// Debounce search filters (300ms delay) to prevent excessive re-renders
84+
const debouncedSearchTitle = useDebounce(searchTitle, 300);
85+
const debouncedSelectedRepo = useDebounce(selectedRepo, 300);
86+
const debouncedStartDate = useDebounce(startDate, 300);
87+
const debouncedEndDate = useDebounce(endDate, 300);
88+
89+
// Debounce username input (400ms delay) to reduce API calls
90+
const debouncedUsername = useDebounce(username, 400);
91+
92+
// Auto-fetch data when debounced username, tab, or page changes
8393
useEffect(() => {
84-
if (username) {
85-
fetchData(username, page + 1, ROWS_PER_PAGE);
94+
if (debouncedUsername) {
95+
setPage(0);
96+
fetchData(debouncedUsername, 1, ROWS_PER_PAGE);
8697
}
87-
}, [tab, page]);
88-
89-
const handleSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
90-
e.preventDefault();
91-
setPage(0);
92-
fetchData(username, 1, ROWS_PER_PAGE);
93-
};
98+
}, [tab, debouncedUsername, fetchData]);
9499

95100
const handlePageChange = (_: unknown, newPage: number) => {
96101
setPage(newPage);
@@ -115,24 +120,25 @@ const Home: React.FC = () => {
115120
}
116121
});
117122
}
118-
if (searchTitle) {
123+
// Use debounced values for filtering
124+
if (debouncedSearchTitle) {
119125
filtered = filtered.filter((item) =>
120-
item.title.toLowerCase().includes(searchTitle.toLowerCase())
126+
item.title.toLowerCase().includes(debouncedSearchTitle.toLowerCase())
121127
);
122128
}
123-
if (selectedRepo) {
129+
if (debouncedSelectedRepo) {
124130
filtered = filtered.filter((item) =>
125-
item.repository_url.includes(selectedRepo)
131+
item.repository_url.includes(debouncedSelectedRepo)
126132
);
127133
}
128-
if (startDate) {
134+
if (debouncedStartDate) {
129135
filtered = filtered.filter(
130-
(item) => new Date(item.created_at) >= new Date(startDate)
136+
(item) => new Date(item.created_at) >= new Date(debouncedStartDate)
131137
);
132138
}
133-
if (endDate) {
139+
if (debouncedEndDate) {
134140
filtered = filtered.filter(
135-
(item) => new Date(item.created_at) <= new Date(endDate)
141+
(item) => new Date(item.created_at) <= new Date(debouncedEndDate)
136142
);
137143
}
138144
return filtered;
@@ -165,48 +171,41 @@ const Home: React.FC = () => {
165171

166172
return (
167173
<Container maxWidth="lg" sx={{ mt: 4, minHeight: "80vh", color: theme.palette.text.primary }}>
168-
{/* Auth Form */}
174+
{/* Auth Inputs */}
169175
<Paper elevation={1} sx={{ p: 2, mb: 4, backgroundColor: theme.palette.background.paper }}>
170-
<form onSubmit={handleSubmit}>
171-
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
172-
<TextField
173-
label="GitHub Username"
174-
value={username}
175-
onChange={(e) => setUsername(e.target.value)}
176-
required
177-
sx={{ flex: 1, minWidth: 150 }}
178-
/>
179-
<TextField
180-
label="Personal Access Token"
181-
value={token}
182-
onChange={(e) => setToken(e.target.value)}
183-
type="password"
184-
sx={{ flex: 1, minWidth: 150 }}
185-
// Helper link to guide users on generating a GitHub Personal Access Token
186-
helperText={
187-
<Link
188-
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
189-
target="_blank"
190-
rel="noopener noreferrer"
191-
sx={{
192-
fontSize: '0.75rem',
193-
color: 'primary.main',
194-
textDecoration: 'none',
195-
'&:hover': {
196-
textDecoration: 'underline',
197-
}
198-
}}
199-
>
200-
How to generate?
201-
</Link>
202-
}
203-
/>
204-
205-
<Button type="submit" variant="contained" sx={{ minWidth: "120px" }}>
206-
Fetch Data
207-
</Button>
208-
</Box>
209-
</form>
176+
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
177+
<TextField
178+
label="GitHub Username"
179+
value={username}
180+
onChange={(e) => setUsername(e.target.value)}
181+
placeholder="Start typing to search..."
182+
sx={{ flex: 1, minWidth: 150 }}
183+
/>
184+
<TextField
185+
label="Personal Access Token (optional)"
186+
value={token}
187+
onChange={(e) => setToken(e.target.value)}
188+
type="password"
189+
sx={{ flex: 1, minWidth: 150 }}
190+
helperText={
191+
<Link
192+
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
193+
target="_blank"
194+
rel="noopener noreferrer"
195+
sx={{
196+
fontSize: '0.75rem',
197+
color: 'primary.main',
198+
textDecoration: 'none',
199+
'&:hover': {
200+
textDecoration: 'underline',
201+
}
202+
}}
203+
>
204+
How to generate?
205+
</Link>
206+
}
207+
/>
208+
</Box>
210209
</Paper>
211210

212211
{/* Filters */}

0 commit comments

Comments
 (0)