Skip to content
Open
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
51 changes: 51 additions & 0 deletions src/renderer/utils/notifications/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import * as apiClient from '../forges/github/client';
import { GITHUB_API_MERGE_BATCH_SIZE } from '../forges/github/enrich';
import {
clearEnrichmentCache,
enrichNotifications,
getNotificationCount,
getUnreadNotificationCount,
Expand All @@ -41,6 +42,7 @@ describe('renderer/utils/notifications/notifications.ts', () => {
vi.mocked(apiClient.fetchNotificationDetailsForList).mockReset();
vi.mocked(apiClient.fetchNotificationDetailsForList).mockResolvedValue(new Map());
vi.mocked(apiClient.fetchIssueByNumber).mockReset();
clearEnrichmentCache();
});

it('getNotificationCount', () => {
Expand Down Expand Up @@ -194,5 +196,54 @@ describe('renderer/utils/notifications/notifications.ts', () => {
expect(fetchNotificationDetailsForListSpy).toHaveBeenCalledTimes(1);
expect(fetchNotificationDetailsForListSpy.mock.calls[0][0]).toHaveLength(50);
});

it('should skip re-fetch when a notification updatedAt is unchanged', async () => {
const fetchNotificationDetailsForListSpy = vi.mocked(
apiClient.fetchNotificationDetailsForList,
);
vi.mocked(apiClient.fetchIssueByNumber).mockResolvedValue({ repository: {} } as never);
fetchNotificationDetailsForListSpy.mockResolvedValue(new Map());

useSettingsStore.setState({ detailedNotifications: true });

const notification = {
...(mockPartialGitifyNotification({
title: 'Issue #1',
type: 'Issue',
url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link,
}) as GitifyNotification),
id: '1',
updatedAt: '2026-01-01T00:00:00Z',
};

// First poll fetches details; second poll with an unchanged
// `updatedAt` must reuse the cached subject and not re-fetch.
await enrichNotifications([notification]);
await enrichNotifications([notification]);

expect(fetchNotificationDetailsForListSpy).toHaveBeenCalledTimes(1);
});

it('should re-fetch when a notification updatedAt changes', async () => {
const fetchNotificationDetailsForListSpy = vi.mocked(
apiClient.fetchNotificationDetailsForList,
);
vi.mocked(apiClient.fetchIssueByNumber).mockResolvedValue({ repository: {} } as never);
fetchNotificationDetailsForListSpy.mockResolvedValue(new Map());

useSettingsStore.setState({ detailedNotifications: true });

const base = mockPartialGitifyNotification({
title: 'Issue #1',
type: 'Issue',
url: 'https://api.github.com/repos/gitify-app/notifications-test/issues/1' as Link,
}) as GitifyNotification;

// A changed `updatedAt` invalidates the cache and forces a re-fetch.
await enrichNotifications([{ ...base, id: '1', updatedAt: '2026-01-01T00:00:00Z' }]);
await enrichNotifications([{ ...base, id: '1', updatedAt: '2026-01-02T00:00:00Z' }]);

expect(fetchNotificationDetailsForListSpy).toHaveBeenCalledTimes(2);
});
});
});
84 changes: 82 additions & 2 deletions src/renderer/utils/notifications/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { useAccountsStore, useSettingsStore } from '../../stores';

import type { Account, AccountNotifications, RawGitifyNotification } from '../../types';
import type {
Account,
AccountNotifications,
GitifySubject,
RawGitifyNotification,
} from '../../types';

import { determineFailureType } from '../api/errors';
import { getAccountUUID } from '../auth/utils';
import { rendererLogError, toError } from '../core/logger';
import { getAdapter } from '../forges/registry';
import { filterBaseNotifications } from './filters/filter';
Expand Down Expand Up @@ -114,6 +120,14 @@ export async function getAllNotifications(): Promise<AccountNotifications[]> {
* original list unchanged otherwise. Details are fetched in batches via
* GraphQL to avoid overwhelming the API.
*
* To avoid re-fetching details for notifications that have not changed,
* previously-enriched subjects are cached keyed by account, notification id,
* and `updatedAt` (see {@link enrichmentCacheKey}). GitHub bumps a
* notification's `updatedAt` whenever its subject changes in a way worth
* surfacing, so an unchanged `updatedAt` means the cached subject is still
* valid and the detail fetch can be skipped entirely. This sharply reduces
* per-poll API request volume for users with many long-lived notifications.
*
* @param notifications - The notifications to enrich.
* @returns The same notifications with subject fields populated from the API.
*/
Expand All @@ -130,7 +144,73 @@ export async function enrichNotifications(
if (!enrich) {
return notifications;
}
return enrich(notifications);

// Reuse cached subjects for notifications whose `updatedAt` is unchanged and
// only fetch details for cache misses, so unchanged notifications are not
// re-fetched on every poll cycle. The cached subject is applied onto the
// freshly-fetched notification so read-state and ordering stay current.
const liveKeys = new Set<string>();
const result: RawGitifyNotification[] = [];
const misses: RawGitifyNotification[] = [];
const missIndexes: number[] = [];

notifications.forEach((notification, index) => {
const key = enrichmentCacheKey(notification);
liveKeys.add(key);

const cachedSubject = enrichmentCache.get(key);
if (cachedSubject) {
result[index] = { ...notification, subject: cachedSubject };
} else {
misses.push(notification);
missIndexes.push(index);
}
});

if (misses.length) {
// Adapters return enriched notifications in the same order as their input,
// so map results back positionally rather than by id.
const enriched = await enrich(misses);
enriched.forEach((enrichedNotification, i) => {
const index = missIndexes[i];
result[index] = enrichedNotification;
enrichmentCache.set(enrichmentCacheKey(enrichedNotification), enrichedNotification.subject);
});
}

// Bound cache growth: drop entries for notifications no longer present.
for (const key of enrichmentCache.keys()) {
if (!liveKeys.has(key)) {
enrichmentCache.delete(key);
}
}

return result;
}

/**
* In-memory cache of enriched subjects keyed by {@link enrichmentCacheKey}.
* Persists across poll cycles so unchanged notifications can skip enrichment,
* and is pruned each cycle to the set of currently-present notifications so it
* stays bounded to the live notification working set.
*/
const enrichmentCache = new Map<string, GitifySubject>();

/**
* Build the enrichment cache key for a notification from its account, id, and
* last-updated timestamp. A change to any of these invalidates the cached
* subject and forces a re-fetch.
*/
function enrichmentCacheKey(notification: RawGitifyNotification): string {
return `${getAccountUUID(notification.account)}:${notification.id}:${notification.updatedAt}`;
}

/**
* Clear the enrichment cache. Intended for use in tests to keep the
* module-level cache from leaking state across cases.
*/
export function clearEnrichmentCache(): void {
enrichmentCache.clear();
}

/**
Expand Down