mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
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:
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user