From 17c6ab4ce0f0513929e1f2e366dfb9715f6aefde Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Wed, 28 Oct 2026 09:12:08 +0100 Subject: [PATCH] perf: recount tags in a hidden tab once it is shown, not on every push Every open tab receives every push, and each one recounted every tag on each Email change: two Email/query per tag, per account, for badges nobody was looking at. Against a server where those queries are slow, the rounds of several background tabs piled up behind each other. A hidden tab now only notes the Email states it was told about and recounts once when it becomes visible. On that return the client also re-checks the server's states and replays them as a push when they moved since its last check; a replay carrying exactly the states the return recount already covered does not trigger a second one. --- .../email-store-hidden-tag-counts.test.ts | 80 +++++++++++++++++++ stores/email-store.ts | 60 +++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 stores/__tests__/email-store-hidden-tag-counts.test.ts diff --git a/stores/__tests__/email-store-hidden-tag-counts.test.ts b/stores/__tests__/email-store-hidden-tag-counts.test.ts new file mode 100644 index 000000000..e76534120 --- /dev/null +++ b/stores/__tests__/email-store-hidden-tag-counts.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '../settings-store'; +import type { Mailbox, StateChange } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Every open tab gets every push. A hidden tab used to recount every tag on +// each one - two queries per tag - for badges nobody was looking at. + +const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox; + +function makeClient() { + return { + getEmails: vi.fn(async () => ({ emails: [], total: 0, hasMore: false, state: 'email-state' })), + getThreads: vi.fn(async () => []), + getTagCounts: vi.fn(async () => ({ work: { total: 1, unread: 1 } })), + getAccountId: () => 'account-a', + } as unknown as IJMAPClient & { getTagCounts: ReturnType }; +} + +const push = (state: string): StateChange => ({ '@type': 'StateChange', changed: { 'account-a': { Email: state } } }); + +let hidden = false; +function setHidden(value: boolean) { + hidden = value; + document.dispatchEvent(new Event('visibilitychange')); +} + +describe('tag counts on pushes in a hidden tab', () => { + let client: ReturnType; + + beforeEach(() => { + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }); + hidden = false; + client = makeClient(); + useAuthStore.setState(useAuthStore.getInitialState()); + useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never); + useEmailStore.setState({ ...useEmailStore.getInitialState(), selectedMailbox: 'inbox', mailboxes: [inbox] }); + }); + + afterEach(() => { + hidden = false; + }); + + it('recounts on every push in a visible tab', async () => { + await useEmailStore.getState().handleStateChange(push('1'), client); + await useEmailStore.getState().handleStateChange(push('2'), client); + + await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(2)); + }); + + it('waits until the tab is shown, then recounts once', async () => { + setHidden(true); + await useEmailStore.getState().handleStateChange(push('1'), client); + await useEmailStore.getState().handleStateChange(push('2'), client); + expect(client.getTagCounts).not.toHaveBeenCalled(); + + setHidden(false); + await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(1)); + + setHidden(true); + setHidden(false); + await Promise.resolve(); + expect(client.getTagCounts).toHaveBeenCalledTimes(1); + }); + + it("does not recount again for the client's replay of the same states on return", async () => { + setHidden(true); + await useEmailStore.getState().handleStateChange(push('2'), client); + setHidden(false); + await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(1)); + + await useEmailStore.getState().handleStateChange(push('2'), client); + expect(client.getTagCounts).toHaveBeenCalledTimes(1); + + await useEmailStore.getState().handleStateChange(push('3'), client); + await vi.waitFor(() => expect(client.getTagCounts).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 32d7e2503..9eb52fe49 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -715,6 +715,64 @@ async function submissionMailboxes( }; } +// --------------------------------------------------------------------------- +// Tag counts in hidden tabs +// +// Every open tab receives every push, and each recounted every tag on each +// one: two queries per tag, per account, per tab. A hidden tab shows no +// badge, so it only notes the Email states it was told about and recounts +// once when it is looked at again. +// +// On that return the client also re-checks the server's states and, when they +// moved since its last check, replays them as a push. The replay is skipped +// for tag counts when it carries exactly the states the return recount +// already covered. +// --------------------------------------------------------------------------- +type TagCountStates = { client: IJMAPClient; emailStates: Record }; +let tagCountsOnReturn: (TagCountStates & { get: () => EmailStore }) | null = null; +let tagCountsCoveredOnReturn: TagCountStates | null = null; +let tagCountsVisibilityListener = false; + +function changedEmailStates(change: StateChange): Record { + const states: Record = {}; + for (const [accountId, types] of Object.entries(change.changed)) { + if (types?.Email) states[accountId] = types.Email; + } + return states; +} + +function recountTagCountsOnReturn(): void { + if (document.hidden || !tagCountsOnReturn) return; + const { client, emailStates, get } = tagCountsOnReturn; + tagCountsOnReturn = null; + tagCountsCoveredOnReturn = { client, emailStates }; + void get().fetchTagCounts(client); +} + +/** fetchTagCounts for a push, unless the tab is hidden or the push is the return's replay. */ +function fetchTagCountsForPush(client: IJMAPClient, change: StateChange, get: () => EmailStore): void { + const emailStates = changedEmailStates(change); + if (typeof document !== 'undefined' && document.hidden) { + const pending = tagCountsOnReturn?.client === client ? tagCountsOnReturn.emailStates : {}; + tagCountsOnReturn = { client, emailStates: { ...pending, ...emailStates }, get }; + if (!tagCountsVisibilityListener) { + tagCountsVisibilityListener = true; + document.addEventListener('visibilitychange', recountTagCountsOnReturn); + } + return; + } + const covered = tagCountsCoveredOnReturn; + tagCountsCoveredOnReturn = null; + tagCountsOnReturn = null; + if ( + covered?.client === client && + Object.entries(emailStates).every(([accountId, state]) => covered.emailStates[accountId] === state) + ) { + return; + } + void get().fetchTagCounts(client); +} + function coalesceRefresh(client: IJMAPClient, key: string, run: () => Promise): Promise { let byKey = inFlightRefreshes.get(client); if (!byKey) { @@ -6324,7 +6382,7 @@ export const useEmailStore = create((set, get) => ({ await get().refreshCurrentMailbox(client); } if (anyEmailChanged) { - get().fetchTagCounts(client); + fetchTagCountsForPush(client, change, get); } }