From d1b12ff7465f02b05b5131ec1f6ec6ecf7fd069f Mon Sep 17 00:00:00 2001 From: Jaspreet Singh <6873201+tagpro@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:19:30 +1000 Subject: [PATCH] fix(notifications): handle a missing X-Total-Count and serialise unread fetches Number(null) is 0, so an absent X-Total-Count header collapsed unreadTotal and readTotal to zero rather than falling back to the loaded count, hiding the unread count and the "Mark All as Read" button while rows were on screen. The 30s poll and a "Load More" click both wrote unreadNotifications and could overlap, letting whichever response landed last win: a poll issued before a "Load More" but resolving after it truncates the list back to the first page, dropping the page the user just asked for. Queue the unread fetches so each reads its offset only once the previous one has settled, and skip the poll while a "Load More" is pending so it cannot discard a loaded page. Addresses review feedback on maxdorninger/MediaManager#564. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SC2BDd7wA7hPa7k2h2yAv1 --- .../dashboard/notifications/+page.svelte | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/web/src/routes/dashboard/notifications/+page.svelte b/web/src/routes/dashboard/notifications/+page.svelte index 83b12b67..0b6f3e09 100644 --- a/web/src/routes/dashboard/notifications/+page.svelte +++ b/web/src/routes/dashboard/notifications/+page.svelte @@ -28,8 +28,10 @@ $: hasMoreRead = readNotifications.length < readTotal; function totalCountOf(response: Response, fallback: number): number { - const total = Number(response.headers.get('X-Total-Count')); - return Number.isNaN(total) ? fallback : total; + const header = response.headers.get('X-Total-Count'); + if (!header) return fallback; + const total = Number(header); + return Number.isFinite(total) ? total : fallback; } function timeOf(notification: Notification): number { @@ -51,15 +53,26 @@ return [...notifications.slice(0, index), notification, ...notifications.slice(index)]; } - async function loadUnread({ reset = false } = {}) { - const offset = reset ? 0 : unreadNotifications.length; - const { data, response } = await client.GET('/api/v1/notification/unread', { - params: { query: { limit: PAGE_SIZE, offset } } - }); - if (!data) return; - - unreadNotifications = reset ? data : [...unreadNotifications, ...data]; - unreadTotal = totalCountOf(response, unreadNotifications.length); + // The 30s poll and a "Load More" click both write unreadNotifications, so they + // are queued rather than run concurrently: overlapping, whichever landed last + // would win, dropping a page or repeating an id in the keyed {#each}. Queuing + // also means each request reads its offset only once the one before it is done. + let unreadQueue: Promise = Promise.resolve(); + + function loadUnread({ reset = false } = {}): Promise { + const fetchPage = async () => { + const offset = reset ? 0 : unreadNotifications.length; + const { data, response } = await client.GET('/api/v1/notification/unread', { + params: { query: { limit: PAGE_SIZE, offset } } + }); + if (!data) return; + + unreadNotifications = reset ? data : [...unreadNotifications, ...data]; + unreadTotal = totalCountOf(response, unreadNotifications.length); + }; + + unreadQueue = unreadQueue.then(fetchPage, fetchPage); + return unreadQueue; } async function loadRead({ reset = false } = {}) { @@ -166,7 +179,7 @@ loadUnread({ reset: true }).finally(() => (loading = false)); const interval = setInterval(() => { - if (loading || markingAllAsRead) return; + if (loading || markingAllAsRead || loadingMoreUnread) return; // Only refresh while a single page is shown, so polling cannot discard // pages the user has loaded. if (unreadNotifications.length > PAGE_SIZE) return;