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
81 changes: 81 additions & 0 deletions web/__test__/helpers/apollo-cache/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createApolloCache>) =>
cache.write({
dataId: 'ROOT_MUTATION',
query: archiveNotification,
variables: { id: 'n1' },
result: { archiveNotification: archivedNotification },
});

const readOverview = (cache: ReturnType<typeof createApolloCache>) =>
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);
});
});
9 changes: 1 addition & 8 deletions web/src/components/Notifications/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down Expand Up @@ -194,7 +187,7 @@ watch(open, (isOpen) => {
<TabsTrigger value="archived" as-child>
<Button variant="ghost" size="sm" class="inline-flex items-center gap-1 px-3 py-1">
<span>{{ t('notifications.sidebar.archivedTab') }}</span>
<span v-if="overview" class="font-normal">({{ readArchivedCount }})</span>
<span v-if="overview" class="font-normal">({{ overview.archive.total }})</span>
</Button>
</TabsTrigger>
</TabsList>
Expand Down
28 changes: 10 additions & 18 deletions web/src/helpers/apollo-cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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.
*
Expand All @@ -133,26 +133,18 @@ 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
* @param cache - Apollo cache instance
* @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.
Expand Down