From 56dcece56c13a38cb2b1a761ad26b3258275c643 Mon Sep 17 00:00:00 2001 From: Jack O'Brien <70115137+jackobrien44@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:14:10 -0500 Subject: [PATCH 1/2] fix(web): stop double-counting notification overview after archive --- .../helpers/apollo-cache/index.test.ts | 68 +++++++++++++++++++ web/src/components/Notifications/Sidebar.vue | 9 +-- web/src/helpers/apollo-cache/index.ts | 28 +++----- 3 files changed, 79 insertions(+), 26 deletions(-) create mode 100644 web/__test__/helpers/apollo-cache/index.test.ts 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..1498a35cd7 --- /dev/null +++ b/web/__test__/helpers/apollo-cache/index.test.ts @@ -0,0 +1,68 @@ +import { gql } from '@apollo/client/core'; +import { createApolloCache } from '~/helpers/apollo-cache'; +import { describe, expect, it } from 'vitest'; + +import { notificationsOverview } from '~/components/Notifications/graphql/notification.query'; + +const archiveMutation = gql` + mutation ArchiveNotification($id: PrefixedID!) { + archiveNotification(id: $id) { + id + } + } +`; + +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: archiveMutation, + variables: { id: 'n1' }, + result: { archiveNotification: { __typename: 'Notification', id: 'n1' } }, + }); + +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. From 98e545518194df8a42c48d6159f5f273e6d159fa Mon Sep 17 00:00:00 2001 From: Jack O'Brien <70115137+jackobrien44@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:30:46 -0500 Subject: [PATCH 2/2] test(web): reuse production archive mutation in cache policy test --- .../helpers/apollo-cache/index.test.ts | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/web/__test__/helpers/apollo-cache/index.test.ts b/web/__test__/helpers/apollo-cache/index.test.ts index 1498a35cd7..8dba28ca89 100644 --- a/web/__test__/helpers/apollo-cache/index.test.ts +++ b/web/__test__/helpers/apollo-cache/index.test.ts @@ -1,16 +1,29 @@ -import { gql } from '@apollo/client/core'; import { createApolloCache } from '~/helpers/apollo-cache'; import { describe, expect, it } from 'vitest'; -import { notificationsOverview } from '~/components/Notifications/graphql/notification.query'; +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 archiveMutation = gql` - mutation ArchiveNotification($id: PrefixedID!) { - archiveNotification(id: $id) { - id - } - } -`; +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, @@ -35,9 +48,9 @@ const overview = (unread: number, archive: number) => ({ const writeArchiveResult = (cache: ReturnType) => cache.write({ dataId: 'ROOT_MUTATION', - query: archiveMutation, + query: archiveNotification, variables: { id: 'n1' }, - result: { archiveNotification: { __typename: 'Notification', id: 'n1' } }, + result: { archiveNotification: archivedNotification }, }); const readOverview = (cache: ReturnType) =>