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.
This commit is contained in:
lucamzanon
2026-10-28 09:12:08 +01:00
parent 7025548ba1
commit 17c6ab4ce0
2 changed files with 139 additions and 1 deletions
@@ -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<typeof vi.fn> };
}
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<typeof makeClient>;
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));
});
});
+59 -1
View File
@@ -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<string, string> };
let tagCountsOnReturn: (TagCountStates & { get: () => EmailStore }) | null = null;
let tagCountsCoveredOnReturn: TagCountStates | null = null;
let tagCountsVisibilityListener = false;
function changedEmailStates(change: StateChange): Record<string, string> {
const states: Record<string, string> = {};
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<void>): Promise<void> {
let byKey = inFlightRefreshes.get(client);
if (!byKey) {
@@ -6324,7 +6382,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await get().refreshCurrentMailbox(client);
}
if (anyEmailChanged) {
get().fetchTagCounts(client);
fetchTagCountsForPush(client, change, get);
}
}