From 996fedc5a484cc959a49d9344d7910e34ab6f92d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:11:30 +0000 Subject: [PATCH 1/8] fix: keep inbox unread count current --- .../(app)/[emailAccountId]/mail/MailShell.tsx | 50 +++++++-- .../mail/inbox-unread-count.test.ts | 75 +++++++++++++ .../mail/inbox-unread-count.ts | 34 ++++++ apps/web/app/api/labels/counts/route.test.ts | 20 +++- apps/web/app/api/labels/counts/route.ts | 46 +++++++- apps/web/hooks/useLabelCounts.test.tsx | 100 ++++++++++++++++++ apps/web/hooks/useLabelCounts.ts | 36 ++++++- 7 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts create mode 100644 apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts create mode 100644 apps/web/hooks/useLabelCounts.test.tsx diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx index e4ceda028b..eaf571fa81 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx @@ -55,6 +55,7 @@ import { useThreadPrefetchCoordinator } from "@/app/(app)/[emailAccountId]/mail/ import { useThreadActions } from "@/app/(app)/[emailAccountId]/mail/use-thread-actions"; import { useThreadSelection } from "@/app/(app)/[emailAccountId]/mail/use-thread-selection"; import { isThreadUnread } from "@/app/(app)/[emailAccountId]/mail/read-state"; +import { getInboxUnreadDelta } from "@/app/(app)/[emailAccountId]/mail/inbox-unread-count"; import { MailLayout, MailSplitKind } from "@/generated/prisma/enums"; import { useChat } from "@/providers/ChatProvider"; import { Sidebar, useSidebar } from "@/components/ui/sidebar"; @@ -135,7 +136,11 @@ export function MailShell() { const { userLabels } = useEmail(); const { visibleLabels, mutate: mutateLabels } = useSplitLabels(); const { folders, mutate: mutateFolders } = useFolders(provider); - const { countsById, mutate: mutateCounts } = useLabelCounts(); + const { + adjustInboxUnread, + countsById, + mutate: mutateCounts, + } = useLabelCounts({ emailAccountId }); const { data: settings, mutate: mutateSettings } = useMailSettings(); const { onOpen: openCompose } = useComposeModal(); const { setInput: setChatInput } = useChat(); @@ -312,11 +317,44 @@ export function MailShell() { const orderedIds = useMemo(() => threads.map(getListThreadKey), [threads]); const selection = useThreadSelection(orderedIds); - const { archive, trash, markRead, setReadState, snooze, undo } = - useThreadActions({ - emailAccountId, - threads, - }); + const { + archive, + trash, + setReadState: queueReadState, + snooze, + undo, + } = useThreadActions({ + emailAccountId, + threads, + }); + const inboxFolderId = folders.find( + (folder) => folder.systemType === "INBOX", + )?.id; + // Behind a ref so setReadState stays referentially stable across thread-list + // refreshes, matching useThreadActions. + const threadsRef = useRef(threads); + threadsRef.current = threads; + const setReadState = useCallback( + async (threadKeys: string[], read: boolean, notifySuccess = true) => { + const queuedKeys = await queueReadState(threadKeys, read, notifySuccess); + if (!isAllAccounts) { + adjustInboxUnread( + getInboxUnreadDelta({ + inboxFolderId, + read, + threadKeys: queuedKeys, + threads: threadsRef.current, + }), + ); + } + return queuedKeys; + }, + [adjustInboxUnread, inboxFolderId, isAllAccounts, queueReadState], + ); + const markRead = useCallback( + (threadKeys: string[]) => setReadState(threadKeys, true, false), + [setReadState], + ); const clampIndex = useCallback( (index: number) => diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts new file mode 100644 index 0000000000..46d592b1e8 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { ListThread } from "./types"; +import { getInboxUnreadDelta } from "./inbox-unread-count"; + +describe("getInboxUnreadDelta", () => { + it("decrements only unread inbox conversations marked as read", () => { + const threads = [ + createThread("unread-inbox", ["INBOX", "UNREAD"]), + createThread("read-inbox", ["INBOX"]), + createThread("unread-archive", ["UNREAD"]), + ]; + + expect( + getInboxUnreadDelta({ + read: true, + threadKeys: threads.map((thread) => thread.id), + threads, + }), + ).toBe(-1); + }); + + it("increments only read inbox conversations marked as unread", () => { + const threads = [ + createThread("read-inbox", ["INBOX"]), + createThread("unread-inbox", ["INBOX", "UNREAD"]), + ]; + + expect( + getInboxUnreadDelta({ + read: false, + threadKeys: threads.map((thread) => thread.id), + threads, + }), + ).toBe(1); + }); + + it("recognizes the Outlook inbox folder", () => { + const thread = createThread("outlook", ["UNREAD"], "outlook-inbox"); + + expect( + getInboxUnreadDelta({ + inboxFolderId: "outlook-inbox", + read: true, + threadKeys: [thread.id], + threads: [thread], + }), + ).toBe(-1); + }); +}); + +function createThread( + id: string, + labelIds: string[], + parentFolderId?: string, +): ListThread { + return { + id, + snippet: "snippet", + plan: undefined, + plans: [], + messages: [ + { + id: `message-${id}`, + threadId: id, + snippet: "snippet", + subject: "Subject", + date: "0", + internalDate: "0", + labelIds, + parentFolderId, + headers: { subject: "Subject" }, + }, + ], + }; +} diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts new file mode 100644 index 0000000000..570f3ca59a --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts @@ -0,0 +1,34 @@ +import { GmailLabel } from "@/utils/gmail/label"; +import { isThreadUnread } from "./read-state"; +import { getListThreadKey, type ListThread } from "./types"; + +export function getInboxUnreadDelta({ + inboxFolderId, + read, + threadKeys, + threads, +}: { + inboxFolderId?: string; + read: boolean; + threadKeys: string[]; + threads: ListThread[]; +}) { + const targets = new Set(threadKeys); + let delta = 0; + + for (const thread of threads) { + if (!targets.has(getListThreadKey(thread))) continue; + const isInInbox = thread.messages.some( + (message) => + message.labelIds?.includes(GmailLabel.INBOX) || + (inboxFolderId && message.parentFolderId === inboxFolderId), + ); + if (!isInInbox) continue; + + const isUnread = isThreadUnread(thread.messages); + if (isUnread === !read) continue; + delta += read ? -1 : 1; + } + + return delta; +} diff --git a/apps/web/app/api/labels/counts/route.test.ts b/apps/web/app/api/labels/counts/route.test.ts index 54adeeff36..1118544404 100644 --- a/apps/web/app/api/labels/counts/route.test.ts +++ b/apps/web/app/api/labels/counts/route.test.ts @@ -113,7 +113,7 @@ describe("GET /api/labels/counts", () => { expect(body.partial).toBe(false); }); - it("caches the response and serves later requests from the cache", async () => { + it("caches label counts but refreshes the inbox count on later requests", async () => { const first = await GET(request()); const firstBody = await first.json(); @@ -126,12 +126,26 @@ describe("GET /api/labels/counts", () => { mockGetLabelById.mockClear(); mockGetLabels.mockClear(); mockRedisGet.mockResolvedValue(firstBody); + mockGetLabelById.mockResolvedValue({ + id: "INBOX", + name: "Inbox", + threadsTotal: 8, + threadsUnread: 1, + }); const second = await GET(request()); + const secondBody = await second.json(); - expect(await second.json()).toEqual(firstBody); + expect(secondBody.counts[0]).toEqual({ + id: "INBOX", + name: "Inbox", + kind: "system", + total: 8, + unread: 1, + }); expect(mockGetLabels).not.toHaveBeenCalled(); - expect(mockGetLabelById).not.toHaveBeenCalled(); + expect(mockGetLabelById).toHaveBeenCalledOnce(); + expect(mockGetLabelById).toHaveBeenCalledWith("INBOX"); }); it("keeps the other counts when a single label lookup fails", async () => { diff --git a/apps/web/app/api/labels/counts/route.ts b/apps/web/app/api/labels/counts/route.ts index 827848b043..8696ac4bfb 100644 --- a/apps/web/app/api/labels/counts/route.ts +++ b/apps/web/app/api/labels/counts/route.ts @@ -64,7 +64,11 @@ export const GET = withEmailProvider( const { emailAccountId } = request.auth; const cached = await getCachedCounts(emailAccountId, logger); - if (cached) return NextResponse.json(cached); + if (cached) { + return NextResponse.json( + await refreshCachedInboxCount({ cached, emailProvider, logger }), + ); + } const { anyFailed, ...response } = await getCounts({ emailProvider, @@ -178,6 +182,46 @@ async function getGmailCounts({ }; } +async function refreshCachedInboxCount({ + cached, + emailProvider, + logger, +}: { + cached: LabelCountsResponse; + emailProvider: EmailProvider; + logger: Logger; +}) { + if (!cached.counts.some((count) => count.id === GmailLabel.INBOX)) { + return cached; + } + + try { + let fresh: { total: number; unread: number }; + if (isGoogleProvider(emailProvider.name)) { + // Not `getInboxStats`: for Gmail that reports message counts, while the + // sidebar shows thread counts. + const label = await emailProvider.getLabelById(GmailLabel.INBOX); + if (!label) return cached; + fresh = { + total: label.threadsTotal ?? 0, + unread: label.threadsUnread ?? 0, + }; + } else { + fresh = await emailProvider.getInboxStats(); + } + + return { + ...cached, + counts: cached.counts.map((count) => + count.id === GmailLabel.INBOX ? { ...count, ...fresh } : count, + ), + }; + } catch (error) { + logger.warn("Failed to refresh cached inbox count", { error }); + return cached; + } +} + function getCacheKey(emailAccountId: string) { return `${CACHE_KEY_PREFIX}:${emailAccountId}`; } diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx new file mode 100644 index 0000000000..13666ff8c2 --- /dev/null +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom + +import type { PropsWithChildren } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mailbox = vi.hoisted(() => { + const listeners = new Set<(emailAccountId: string) => void>(); + return { + emit(emailAccountId: string) { + for (const listener of listeners) listener(emailAccountId); + }, + reset() { + listeners.clear(); + }, + subscribe: vi.fn((listener: (emailAccountId: string) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + }; +}); + +vi.mock("@/utils/email-cache/mailbox", () => ({ + subscribeToMailboxStore: mailbox.subscribe, +})); + +import { useLabelCounts } from "./useLabelCounts"; + +const initialResponse = { + counts: [ + { + id: "INBOX", + name: "Inbox", + kind: "system" as const, + total: 10, + unread: 4, + }, + ], + partial: false, +}; + +describe("useLabelCounts", () => { + beforeEach(() => { + mailbox.reset(); + vi.clearAllMocks(); + }); + + it("refreshes when the active account mailbox changes", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + renderHook(() => useLabelCounts({ emailAccountId: "account-1" }), { + wrapper: createWrapper(fetcher), + }); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + + act(() => mailbox.emit("account-2")); + expect(fetcher).toHaveBeenCalledOnce(); + + act(() => mailbox.emit("account-1")); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + }); + + it("updates the inbox unread count without waiting for revalidation", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + + act(() => { + result.current.adjustInboxUnread(-1); + }); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + expect(fetcher).toHaveBeenCalledOnce(); + }); +}); + +function createWrapper(fetcher: () => unknown) { + return function Wrapper({ children }: PropsWithChildren) { + return ( + new Map(), + shouldRetryOnError: false, + }} + > + {children} + + ); + }; +} diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 83ff2e5718..79fff5bc4d 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -1,17 +1,48 @@ -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import useSWR from "swr"; import type { LabelCountsResponse } from "@/app/api/labels/counts/route"; +import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox"; +import { GmailLabel } from "@/utils/gmail/label"; /** * Unread/total counts per label, Gmail category, or Outlook folder. * Deliberately not blocking: the sidebar renders without counts and fills in. */ -export function useLabelCounts() { +export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { const { data, error, isLoading, mutate } = useSWR( "/api/labels/counts", { shouldRetryOnError: false }, ); + useEffect( + () => + subscribeToMailboxStore((changedAccountId) => { + if (changedAccountId === emailAccountId) mutate(); + }), + [emailAccountId, mutate], + ); + + const adjustInboxUnread = useCallback( + (delta: number) => { + if (!delta) return; + mutate( + (current) => { + if (!current) return current; + return { + ...current, + counts: current.counts.map((count) => + count.id === GmailLabel.INBOX + ? { ...count, unread: Math.max(0, count.unread + delta) } + : count, + ), + }; + }, + { revalidate: false }, + ); + }, + [mutate], + ); + const countsById = useMemo( () => new Map((data?.counts ?? []).map((count) => [count.id, count])), [data?.counts], @@ -23,5 +54,6 @@ export function useLabelCounts() { isLoading, error, mutate, + adjustInboxUnread, }; } From 9a10a4edf2286476ade0dcc593ebd534117335d4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:16:01 +0000 Subject: [PATCH 2/8] fix: retain folder IDs in thread lists --- apps/web/utils/email-cache/mailbox.ts | 1 + apps/web/utils/threads/load.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/utils/email-cache/mailbox.ts b/apps/web/utils/email-cache/mailbox.ts index 971ac6001e..76267e7bfd 100644 --- a/apps/web/utils/email-cache/mailbox.ts +++ b/apps/web/utils/email-cache/mailbox.ts @@ -577,6 +577,7 @@ function toListMessage(message: ParsedMessage) { date: message.date, internalDate: message.internalDate, labelIds: message.labelIds, + parentFolderId: message.parentFolderId, headers: message.headers, }; } diff --git a/apps/web/utils/threads/load.ts b/apps/web/utils/threads/load.ts index 3a91392063..3e6c85270a 100644 --- a/apps/web/utils/threads/load.ts +++ b/apps/web/utils/threads/load.ts @@ -116,6 +116,7 @@ export function toListThreads({ threads, nextPageToken }: LoadedThreads) { date: message.date, internalDate: message.internalDate, labelIds: message.labelIds, + parentFolderId: message.parentFolderId, headers: message.headers, })), })), From 9897eef53bc67f3e85fd6329b7ff644de41926b6 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:22:25 +0000 Subject: [PATCH 3/8] fix: stabilize unread count updates --- .../(app)/[emailAccountId]/mail/MailShell.tsx | 3 +- apps/web/hooks/useLabelCounts.test.tsx | 33 ++++++++++--------- apps/web/utils/email-cache/mailbox.ts | 4 ++- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx index eaf571fa81..d1d224d260 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx @@ -336,6 +336,7 @@ export function MailShell() { threadsRef.current = threads; const setReadState = useCallback( async (threadKeys: string[], read: boolean, notifySuccess = true) => { + const threadsBeforeQueue = threadsRef.current; const queuedKeys = await queueReadState(threadKeys, read, notifySuccess); if (!isAllAccounts) { adjustInboxUnread( @@ -343,7 +344,7 @@ export function MailShell() { inboxFolderId, read, threadKeys: queuedKeys, - threads: threadsRef.current, + threads: threadsBeforeQueue, }), ); } diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 13666ff8c2..565fc95ce3 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react"; import { act, renderHook, waitFor } from "@testing-library/react"; import { SWRConfig } from "swr"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useLabelCounts } from "./useLabelCounts"; const mailbox = vi.hoisted(() => { const listeners = new Set<(emailAccountId: string) => void>(); @@ -16,7 +17,9 @@ const mailbox = vi.hoisted(() => { }, subscribe: vi.fn((listener: (emailAccountId: string) => void) => { listeners.add(listener); - return () => listeners.delete(listener); + return () => { + listeners.delete(listener); + }; }), }; }); @@ -25,8 +28,6 @@ vi.mock("@/utils/email-cache/mailbox", () => ({ subscribeToMailboxStore: mailbox.subscribe, })); -import { useLabelCounts } from "./useLabelCounts"; - const initialResponse = { counts: [ { @@ -84,17 +85,17 @@ describe("useLabelCounts", () => { }); function createWrapper(fetcher: () => unknown) { - return function Wrapper({ children }: PropsWithChildren) { - return ( - new Map(), - shouldRetryOnError: false, - }} - > - {children} - - ); - }; + const Wrapper = ({ children }: PropsWithChildren) => ( + new Map(), + shouldRetryOnError: false, + }} + > + {children} + + ); + + return Wrapper; } diff --git a/apps/web/utils/email-cache/mailbox.ts b/apps/web/utils/email-cache/mailbox.ts index 76267e7bfd..bfa1a9994a 100644 --- a/apps/web/utils/email-cache/mailbox.ts +++ b/apps/web/utils/email-cache/mailbox.ts @@ -433,7 +433,9 @@ export function subscribeToMailboxStore( listener: (emailAccountId: string) => void, ) { mailboxListeners.add(listener); - return () => mailboxListeners.delete(listener); + return () => { + mailboxListeners.delete(listener); + }; } export function notifyMailboxStoreChange(emailAccountId: string) { From 19f405e100d634cb0491a70ad357617fb1468dfa Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:27:50 +0000 Subject: [PATCH 4/8] fix: handle unread count edge cases --- .../(app)/[emailAccountId]/mail/MailShell.tsx | 9 +++- .../mail/inbox-unread-count.test.ts | 32 ++++++------ .../mail/inbox-unread-count.ts | 20 +++++-- apps/web/hooks/useLabelCounts.test.tsx | 24 +++++++++ apps/web/hooks/useLabelCounts.ts | 52 +++++++++++++------ 5 files changed, 101 insertions(+), 36 deletions(-) diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx index d1d224d260..88505c9fae 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx @@ -341,6 +341,7 @@ export function MailShell() { if (!isAllAccounts) { adjustInboxUnread( getInboxUnreadDelta({ + countByMessage: isOutlook, inboxFolderId, read, threadKeys: queuedKeys, @@ -350,7 +351,13 @@ export function MailShell() { } return queuedKeys; }, - [adjustInboxUnread, inboxFolderId, isAllAccounts, queueReadState], + [ + adjustInboxUnread, + inboxFolderId, + isAllAccounts, + isOutlook, + queueReadState, + ], ); const markRead = useCallback( (threadKeys: string[]) => setReadState(threadKeys, true, false), diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts index 46d592b1e8..bf0ebe05d4 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts @@ -34,17 +34,18 @@ describe("getInboxUnreadDelta", () => { ).toBe(1); }); - it("recognizes the Outlook inbox folder", () => { - const thread = createThread("outlook", ["UNREAD"], "outlook-inbox"); + it("counts each unread Outlook inbox message", () => { + const thread = createThread("outlook", ["UNREAD"], "outlook-inbox", 2); expect( getInboxUnreadDelta({ + countByMessage: true, inboxFolderId: "outlook-inbox", read: true, threadKeys: [thread.id], threads: [thread], }), - ).toBe(-1); + ).toBe(-2); }); }); @@ -52,24 +53,23 @@ function createThread( id: string, labelIds: string[], parentFolderId?: string, + messageCount = 1, ): ListThread { return { id, snippet: "snippet", plan: undefined, plans: [], - messages: [ - { - id: `message-${id}`, - threadId: id, - snippet: "snippet", - subject: "Subject", - date: "0", - internalDate: "0", - labelIds, - parentFolderId, - headers: { subject: "Subject" }, - }, - ], + messages: Array.from({ length: messageCount }, (_, index) => ({ + id: `message-${id}-${index}`, + threadId: id, + snippet: "snippet", + subject: "Subject", + date: "0", + internalDate: "0", + labelIds, + parentFolderId, + headers: { subject: "Subject" }, + })), }; } diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts index 570f3ca59a..37bb2bec6e 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts @@ -3,11 +3,13 @@ import { isThreadUnread } from "./read-state"; import { getListThreadKey, type ListThread } from "./types"; export function getInboxUnreadDelta({ + countByMessage, inboxFolderId, read, threadKeys, threads, }: { + countByMessage?: boolean; inboxFolderId?: string; read: boolean; threadKeys: string[]; @@ -18,10 +20,20 @@ export function getInboxUnreadDelta({ for (const thread of threads) { if (!targets.has(getListThreadKey(thread))) continue; - const isInInbox = thread.messages.some( - (message) => - message.labelIds?.includes(GmailLabel.INBOX) || - (inboxFolderId && message.parentFolderId === inboxFolderId), + if (countByMessage) { + const affectedMessages = thread.messages.filter((message) => { + const isInInbox = + message.labelIds?.includes(GmailLabel.INBOX) || + (inboxFolderId && message.parentFolderId === inboxFolderId); + const isUnread = message.labelIds?.includes(GmailLabel.UNREAD) ?? false; + return isInInbox && isUnread !== !read; + }); + delta += affectedMessages.length * (read ? -1 : 1); + continue; + } + + const isInInbox = thread.messages.some((message) => + message.labelIds?.includes(GmailLabel.INBOX), ); if (!isInInbox) continue; diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 565fc95ce3..0acfcfc453 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -82,6 +82,30 @@ describe("useLabelCounts", () => { ); expect(fetcher).toHaveBeenCalledOnce(); }); + + it("applies an unread delta queued before counts load", async () => { + let resolveResponse: + | ((response: typeof initialResponse) => void) + | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => expect(resolveResponse).toBeTypeOf("function")); + act(() => result.current.adjustInboxUnread(-1)); + await act(async () => resolveResponse?.(initialResponse)); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + }); }); function createWrapper(fetcher: () => unknown) { diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 79fff5bc4d..9b72dac9ba 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import useSWR from "swr"; import type { LabelCountsResponse } from "@/app/api/labels/counts/route"; import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox"; @@ -13,6 +13,9 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { "/api/labels/counts", { shouldRetryOnError: false }, ); + const dataRef = useRef(data); + const pendingInboxUnreadDelta = useRef(0); + dataRef.current = data; useEffect( () => @@ -22,23 +25,27 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { [emailAccountId, mutate], ); + useEffect(() => { + if (!data || !pendingInboxUnreadDelta.current) return; + const delta = pendingInboxUnreadDelta.current; + pendingInboxUnreadDelta.current = 0; + mutate((current) => applyInboxUnreadDelta(current, delta), { + revalidate: false, + }); + }, [data, mutate]); + const adjustInboxUnread = useCallback( (delta: number) => { if (!delta) return; - mutate( - (current) => { - if (!current) return current; - return { - ...current, - counts: current.counts.map((count) => - count.id === GmailLabel.INBOX - ? { ...count, unread: Math.max(0, count.unread + delta) } - : count, - ), - }; - }, - { revalidate: false }, - ); + if (!dataRef.current) { + pendingInboxUnreadDelta.current += delta; + return; + } + const totalDelta = pendingInboxUnreadDelta.current + delta; + pendingInboxUnreadDelta.current = 0; + mutate((current) => applyInboxUnreadDelta(current, totalDelta), { + revalidate: false, + }); }, [mutate], ); @@ -57,3 +64,18 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { adjustInboxUnread, }; } + +function applyInboxUnreadDelta( + current: LabelCountsResponse | undefined, + delta: number, +) { + if (!current) return current; + return { + ...current, + counts: current.counts.map((count) => + count.id === GmailLabel.INBOX + ? { ...count, unread: Math.max(0, count.unread + delta) } + : count, + ), + }; +} From 6899b4688dfd6563ff457d399e73ff05285c2029 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:39:15 +0000 Subject: [PATCH 5/8] fix: retain pending unread deltas --- apps/web/hooks/useLabelCounts.test.tsx | 19 ++++++++++++++ apps/web/hooks/useLabelCounts.ts | 35 ++++++++++++++++---------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 0acfcfc453..6cc7344827 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -106,6 +106,25 @@ describe("useLabelCounts", () => { expect(result.current.countsById.get("INBOX")?.unread).toBe(3), ); }); + + it("retains an unread delta when a partial response omits the inbox", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ counts: [], partial: true }) + .mockResolvedValueOnce(initialResponse); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + act(() => result.current.adjustInboxUnread(-1)); + act(() => mailbox.emit("account-1")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + }); }); function createWrapper(fetcher: () => unknown) { diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 9b72dac9ba..8346335316 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -25,29 +25,38 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { [emailAccountId, mutate], ); + const applyPendingInboxUnreadDelta = useCallback(() => { + mutate( + (current) => { + if ( + !pendingInboxUnreadDelta.current || + !current?.counts.some((count) => count.id === GmailLabel.INBOX) + ) { + return current; + } + const delta = pendingInboxUnreadDelta.current; + pendingInboxUnreadDelta.current = 0; + return applyInboxUnreadDelta(current, delta); + }, + { revalidate: false }, + ); + }, [mutate]); + useEffect(() => { if (!data || !pendingInboxUnreadDelta.current) return; - const delta = pendingInboxUnreadDelta.current; - pendingInboxUnreadDelta.current = 0; - mutate((current) => applyInboxUnreadDelta(current, delta), { - revalidate: false, - }); - }, [data, mutate]); + applyPendingInboxUnreadDelta(); + }, [applyPendingInboxUnreadDelta, data]); const adjustInboxUnread = useCallback( (delta: number) => { if (!delta) return; + pendingInboxUnreadDelta.current += delta; if (!dataRef.current) { - pendingInboxUnreadDelta.current += delta; return; } - const totalDelta = pendingInboxUnreadDelta.current + delta; - pendingInboxUnreadDelta.current = 0; - mutate((current) => applyInboxUnreadDelta(current, totalDelta), { - revalidate: false, - }); + applyPendingInboxUnreadDelta(); }, - [mutate], + [applyPendingInboxUnreadDelta], ); const countsById = useMemo( From c4ee7f6ed37a81c21362d74036c5a03eac2106ca Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:46:26 +0000 Subject: [PATCH 6/8] fix: scope unread deltas by account --- apps/web/hooks/useLabelCounts.test.tsx | 23 +++++++++++++++++++++++ apps/web/hooks/useLabelCounts.ts | 15 +++++++++------ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 6cc7344827..03d34b4849 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -125,6 +125,29 @@ describe("useLabelCounts", () => { expect(result.current.countsById.get("INBOX")?.unread).toBe(3), ); }); + + it("discards a pending unread delta when the account changes", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ counts: [], partial: true }) + .mockResolvedValueOnce(initialResponse); + const { result, rerender } = renderHook( + ({ emailAccountId }) => useLabelCounts({ emailAccountId }), + { + initialProps: { emailAccountId: "account-1" }, + wrapper: createWrapper(fetcher), + }, + ); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + act(() => result.current.adjustInboxUnread(-1)); + rerender({ emailAccountId: "account-2" }); + act(() => mailbox.emit("account-2")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + }); }); function createWrapper(fetcher: () => unknown) { diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 8346335316..a5bac4e75b 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -14,7 +14,10 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { { shouldRetryOnError: false }, ); const dataRef = useRef(data); - const pendingInboxUnreadDelta = useRef(0); + const pendingInboxUnread = useRef({ emailAccountId, delta: 0 }); + if (pendingInboxUnread.current.emailAccountId !== emailAccountId) { + pendingInboxUnread.current = { emailAccountId, delta: 0 }; + } dataRef.current = data; useEffect( @@ -29,13 +32,13 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { mutate( (current) => { if ( - !pendingInboxUnreadDelta.current || + !pendingInboxUnread.current.delta || !current?.counts.some((count) => count.id === GmailLabel.INBOX) ) { return current; } - const delta = pendingInboxUnreadDelta.current; - pendingInboxUnreadDelta.current = 0; + const delta = pendingInboxUnread.current.delta; + pendingInboxUnread.current.delta = 0; return applyInboxUnreadDelta(current, delta); }, { revalidate: false }, @@ -43,14 +46,14 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { }, [mutate]); useEffect(() => { - if (!data || !pendingInboxUnreadDelta.current) return; + if (!data || !pendingInboxUnread.current.delta) return; applyPendingInboxUnreadDelta(); }, [applyPendingInboxUnreadDelta, data]); const adjustInboxUnread = useCallback( (delta: number) => { if (!delta) return; - pendingInboxUnreadDelta.current += delta; + pendingInboxUnread.current.delta += delta; if (!dataRef.current) { return; } From 62980eb4d2737d4f1d7c9793a1bc25ccf93fbaf4 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:54:48 +0000 Subject: [PATCH 7/8] fix: ignore stale account count updates --- apps/web/hooks/useLabelCounts.test.tsx | 20 ++++++++++++++++++++ apps/web/hooks/useLabelCounts.ts | 12 +++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 03d34b4849..429b04cdf3 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -148,6 +148,26 @@ describe("useLabelCounts", () => { expect(result.current.countsById.get("INBOX")?.unread).toBe(4), ); }); + + it("ignores an unread update resumed from the previous account", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + const { result, rerender } = renderHook( + ({ emailAccountId }) => useLabelCounts({ emailAccountId }), + { + initialProps: { emailAccountId: "account-1" }, + wrapper: createWrapper(fetcher), + }, + ); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + const previousAccountAdjustInboxUnread = result.current.adjustInboxUnread; + rerender({ emailAccountId: "account-2" }); + act(() => previousAccountAdjustInboxUnread(-1)); + + expect(result.current.countsById.get("INBOX")?.unread).toBe(4); + }); }); function createWrapper(fetcher: () => unknown) { diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index a5bac4e75b..4ad70fbabe 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -32,6 +32,7 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { mutate( (current) => { if ( + pendingInboxUnread.current.emailAccountId !== emailAccountId || !pendingInboxUnread.current.delta || !current?.counts.some((count) => count.id === GmailLabel.INBOX) ) { @@ -43,7 +44,7 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { }, { revalidate: false }, ); - }, [mutate]); + }, [emailAccountId, mutate]); useEffect(() => { if (!data || !pendingInboxUnread.current.delta) return; @@ -52,14 +53,19 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { const adjustInboxUnread = useCallback( (delta: number) => { - if (!delta) return; + if ( + !delta || + pendingInboxUnread.current.emailAccountId !== emailAccountId + ) { + return; + } pendingInboxUnread.current.delta += delta; if (!dataRef.current) { return; } applyPendingInboxUnreadDelta(); }, - [applyPendingInboxUnreadDelta], + [applyPendingInboxUnreadDelta, emailAccountId], ); const countsById = useMemo( From 92fabcd914f301c6bb17ad52a220c4972a58957e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:04:40 +0000 Subject: [PATCH 8/8] fix: reset unread state after commit --- apps/web/hooks/useLabelCounts.test.tsx | 10 +++++++++- apps/web/hooks/useLabelCounts.ts | 15 +++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx index 429b04cdf3..e6088315ea 100644 --- a/apps/web/hooks/useLabelCounts.test.tsx +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -111,7 +111,7 @@ describe("useLabelCounts", () => { const fetcher = vi .fn() .mockResolvedValueOnce({ counts: [], partial: true }) - .mockResolvedValueOnce(initialResponse); + .mockResolvedValue(initialResponse); const { result } = renderHook( () => useLabelCounts({ emailAccountId: "account-1" }), { wrapper: createWrapper(fetcher) }, @@ -147,6 +147,14 @@ describe("useLabelCounts", () => { await waitFor(() => expect(result.current.countsById.get("INBOX")?.unread).toBe(4), ); + + rerender({ emailAccountId: "account-1" }); + act(() => mailbox.emit("account-1")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + expect(fetcher).toHaveBeenCalledTimes(3); }); it("ignores an unread update resumed from the previous account", async () => { diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 4ad70fbabe..f1aee893f8 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -1,4 +1,10 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; import useSWR from "swr"; import type { LabelCountsResponse } from "@/app/api/labels/counts/route"; import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox"; @@ -15,11 +21,12 @@ export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { ); const dataRef = useRef(data); const pendingInboxUnread = useRef({ emailAccountId, delta: 0 }); - if (pendingInboxUnread.current.emailAccountId !== emailAccountId) { - pendingInboxUnread.current = { emailAccountId, delta: 0 }; - } dataRef.current = data; + useLayoutEffect(() => { + pendingInboxUnread.current = { emailAccountId, delta: 0 }; + }, [emailAccountId]); + useEffect( () => subscribeToMailboxStore((changedAccountId) => {