diff --git a/web/__test__/helpers/apollo-cache/index.test.ts b/web/__test__/helpers/apollo-cache/index.test.ts new file mode 100644 index 0000000000..8dba28ca89 --- /dev/null +++ b/web/__test__/helpers/apollo-cache/index.test.ts @@ -0,0 +1,81 @@ +import { createApolloCache } from '~/helpers/apollo-cache'; +import { describe, expect, it } from 'vitest'; + +import { + archiveNotification, + NOTIFICATION_FRAGMENT, + notificationsOverview, +} from '~/components/Notifications/graphql/notification.query'; +import { makeFragmentData } from '~/composables/gql/fragment-masking'; +import { NotificationImportance, NotificationType } from '~/composables/gql/graphql'; + +const archivedNotification = makeFragmentData( + { + __typename: 'Notification', + id: 'n1', + title: 'Unraid Status', + subject: 'Mover finished', + description: 'Mover finished', + importance: NotificationImportance.INFO, + link: null, + type: NotificationType.ARCHIVE, + timestamp: '2026-10-31T19:06:09.000Z', + formattedTimestamp: '31-10-2026 19:06', + }, + NOTIFICATION_FRAGMENT +); + +const counts = (total: number) => ({ + __typename: 'NotificationCounts' as const, + info: total, + warning: 0, + alert: 0, + total, +}); + +const overview = (unread: number, archive: number) => ({ + notifications: { + __typename: 'Notifications' as const, + id: 'notifications', + overview: { + __typename: 'NotificationOverview' as const, + unread: counts(unread), + archive: { __typename: 'NotificationCounts' as const, total: archive }, + }, + }, +}); + +const writeArchiveResult = (cache: ReturnType) => + cache.write({ + dataId: 'ROOT_MUTATION', + query: archiveNotification, + variables: { id: 'n1' }, + result: { archiveNotification: archivedNotification }, + }); + +const readOverview = (cache: ReturnType) => + cache.readQuery({ query: notificationsOverview })?.notifications.overview; + +describe('archiveNotification cache policy', () => { + it('keeps the server overview when the subscription arrives before the mutation result', () => { + const cache = createApolloCache(); + cache.writeQuery({ query: notificationsOverview, data: overview(1, 0) }); + + cache.writeQuery({ query: notificationsOverview, data: overview(0, 1) }); + writeArchiveResult(cache); + + expect(readOverview(cache)?.unread.total).toBe(0); + expect(readOverview(cache)?.archive.total).toBe(1); + }); + + it('keeps the server overview when the mutation result arrives first', () => { + const cache = createApolloCache(); + cache.writeQuery({ query: notificationsOverview, data: overview(1, 0) }); + + writeArchiveResult(cache); + cache.writeQuery({ query: notificationsOverview, data: overview(0, 1) }); + + expect(readOverview(cache)?.unread.total).toBe(0); + expect(readOverview(cache)?.archive.total).toBe(1); + }); +}); diff --git a/web/src/components/Notifications/Sidebar.vue b/web/src/components/Notifications/Sidebar.vue index d2e4d117f1..858f9bbae6 100644 --- a/web/src/components/Notifications/Sidebar.vue +++ b/web/src/components/Notifications/Sidebar.vue @@ -129,13 +129,6 @@ const overview = computed(() => { return result.value.notifications.overview; }); -/** This recalculates the archived count due to notifications going to archived + unread when they are in an Unread state. */ -const readArchivedCount = computed(() => { - if (!overview.value) return 0; - const { archive, unread } = overview.value; - return Math.max(0, archive.total - unread.total); -}); - const prepareToViewNotifications = () => { void recalculateOverview(); }; @@ -194,7 +187,7 @@ watch(open, (isOpen) => { diff --git a/web/src/helpers/apollo-cache/index.ts b/web/src/helpers/apollo-cache/index.ts index a08380245a..78a8f59d1b 100644 --- a/web/src/helpers/apollo-cache/index.ts +++ b/web/src/helpers/apollo-cache/index.ts @@ -4,10 +4,7 @@ import { mergeAndDedup } from '~/helpers/apollo-cache/merge'; import type { InMemoryCacheConfig } from '@apollo/client/core'; import type { NotificationOverview } from '~/composables/gql/graphql'; -import { - getNotifications, - notificationsOverview, -} from '~/components/Notifications/graphql/notification.query'; +import { getNotifications } from '~/components/Notifications/graphql/notification.query'; import { NotificationType } from '~/composables/gql/graphql'; import { NotificationTypename } from '~/composables/gql/typename'; @@ -121,9 +118,12 @@ const defaultCacheConfig: InMemoryCacheConfig = { }, archiveNotification: { /** - * Optimistically decrements unread total from overview & * Ensures newly archived notifications appear in the archive list without a page reload. * + * Overview counts are left alone: the server publishes the new overview over the + * `notificationsOverview` subscription, so a local decrement would count the + * archive twice (e.g. an unread total of -1). + * * When a notification is archived, we need to evict the cached archive list to force a refetch. * This ensures the archived notification appears in the correct sorted position. * @@ -133,12 +133,11 @@ const defaultCacheConfig: InMemoryCacheConfig = { * * This function: * - * 1. Optimistically updates notification overview - * 2. Checks if the cache has an archive list. If not, this function is a no-op. - * 3. If the list has items, evicts just the archive list - * 4. If it is empty, evicts the entire notifications cache - * 5. Runs garbage collection to clean up orphaned references - * 6. Returns the original mutation result + * 1. Checks if the cache has an archive list. If not, this function is a no-op. + * 2. If the list has items, evicts just the archive list + * 3. If it is empty, evicts the entire notifications cache + * 4. Runs garbage collection to clean up orphaned references + * 5. Returns the original mutation result * * @param _ - Existing cache value (unused) * @param incoming - Result (i.e. the archived notification) from the server after archiving @@ -146,13 +145,6 @@ const defaultCacheConfig: InMemoryCacheConfig = { * @returns The incoming result to be cached */ merge(_, incoming, { cache }) { - cache.updateQuery({ query: notificationsOverview }, (data) => { - if (!data) return; - const update = structuredClone(data); - update.notifications.overview.unread.total--; - return update; - }); - const archiveQuery = cache.readQuery({ query: getNotifications, // @ts-expect-error the cache only uses the filter type; the limit & offset are superfluous.