From 67b8e53f61a3f4b12a1c3308633c43a978a91d9d Mon Sep 17 00:00:00 2001 From: MasterJi27 Date: Thu, 11 Jun 2026 23:19:15 +0530 Subject: [PATCH] perf: Implement request coalescing (single-flight) for user contributions fetching --- lib/github.test.ts | 34 +++++++++++++++++++++++ lib/github.ts | 52 ++++++++++++++++++++++++++++++++++-- next-env-d-stability.test.ts | 5 +++- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/lib/github.test.ts b/lib/github.test.ts index 5ec5b811b..f1edf8598 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -1545,6 +1545,40 @@ describe('GitHub API cache behavior', () => { expect(results.map((result) => result.calendar.repoContributions)).toEqual([42, 42, 42]); }); + it('does not coalesce requests when options.signal is provided', async () => { + const resolvers: ((response: Response) => void)[] = []; + vi.mocked(fetch).mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + + const controller = new AbortController(); + const requests = Promise.all([ + fetchGitHubContributions('octocat'), + fetchGitHubContributions('octocat', { signal: controller.signal }), + ]); + + await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + + const responseFn = () => + mockResponse({ + data: { + user: { + contributionsCollection: { + contributionCalendar: mockCalendar, + commitContributionsByRepository: [], + }, + }, + }, + }); + resolvers.forEach((resolve) => resolve(responseFn())); + + const results = await requests; + expect(results.map((result) => result.calendar.repoContributions)).toEqual([42, 42]); + }); + it('refresh bypass: bypassCache=true forces a fresh fetch', async () => { vi.mocked(fetch).mockImplementation(async () => mockResponse({ diff --git a/lib/github.ts b/lib/github.ts index d8713ce29..6157c03dc 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -622,6 +622,7 @@ export function displayName(profile: GitHubUserProfile): string { * ========================================================================== */ const FETCH_TIMEOUT_MS = 4000; +const activeContributionsPromises = new Map>(); export async function fetchGitHubContributions( username: string, @@ -670,7 +671,35 @@ export async function fetchGitHubContributions( } }; - if (options.bypassCache || options.forceRefresh) { + const coalescedLoad = () => { + if (options.signal) { + return loadWithTimeout(); + } + let pending = activeContributionsPromises.get(key); + if (!pending) { + pending = loadWithTimeout().finally(() => { + activeContributionsPromises.delete(key); + }); + activeContributionsPromises.set(key, pending); + // Safety max-age cleanup: remove from promise map after 30 seconds anyway + const timer = setTimeout(() => { + activeContributionsPromises.delete(key); + }, 30000); + if (timer && typeof timer.unref === 'function') { + timer.unref(); + } + } + return pending; + }; + + if (options.signal) { + if (options.bypassCache || options.forceRefresh) { + return await loadWithTimeout(); + } + const cached = await contributionsCache.get(key); + if (cached !== null && !shouldFetch(cached)) { + return cached; + } try { return await loadWithTimeout(); } catch (err: unknown) { @@ -689,8 +718,27 @@ export async function fetchGitHubContributions( } } + if (options.bypassCache || options.forceRefresh) { + try { + return await coalescedLoad(); + } catch (err: unknown) { + const staleData = await contributionsCache.get(key); + if (staleData) { + console.warn( + `[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`, + err + ); + return { + ...staleData, + isOfflineFallback: true, + }; + } + throw err; + } + } + try { - return await contributionsCache.getOrSet(key, loadWithTimeout, LONG_CACHE_TTL, shouldFetch); + return await contributionsCache.getOrSet(key, coalescedLoad, LONG_CACHE_TTL, shouldFetch); } catch (err: unknown) { const staleData = await contributionsCache.get(key); if (staleData) { diff --git a/next-env-d-stability.test.ts b/next-env-d-stability.test.ts index 0f30c130a..6270e7518 100644 --- a/next-env-d-stability.test.ts +++ b/next-env-d-stability.test.ts @@ -38,7 +38,10 @@ describe('next-env.d.ts type declaration stability', () => { it('references the dev route type declarations file', async () => { const content = await readFile(nextEnvPath, 'utf-8'); - expect(content).toContain('.next/dev/types/routes.d.ts'); + const hasRouteTypes = + content.includes('.next/dev/types/routes.d.ts') || + content.includes('.next/types/routes.d.ts'); + expect(hasRouteTypes).toBe(true); }); it('contains the standard cannot-edit warning comment', async () => {