Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>((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({
Expand Down
52 changes: 50 additions & 2 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ export function displayName(profile: GitHubUserProfile): string {
* ========================================================================== */

const FETCH_TIMEOUT_MS = 4000;
const activeContributionsPromises = new Map<string, Promise<ExtendedContributionData>>();

export async function fetchGitHubContributions(
username: string,
Expand Down Expand Up @@ -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;
};
Comment on lines +674 to +693

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) {
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion next-env-d-stability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading