fix: stop the contacts tab downloading every card on each open

Opening Contacts re-ran the full `ContactCard/query` + `ContactCard/get`
for every account (58 KB on the audit server) although the cards are
loaded at startup and every live `ContactCard` state change already
refetches them.

The tab now only fetches when no complete fetch happened this session,
after a reset or account switch, after a filtered load, or when the
last full fetch is more than 5 minutes old, which covers changes missed
while the app was in the background or live updates were down. Pull
to refresh and the Refresh buttons still force a reload (PF6).
This commit is contained in:
Linus Rath
2026-09-24 19:03:35 +02:00
parent c888e95c5a
commit 668e01023c
3 changed files with 74 additions and 5 deletions
+5 -2
View File
@@ -106,6 +106,7 @@ export default function ContactsScreen() {
const hydrated = useContactsStore((s) => s.hydrated);
const fetchAddressBooks = useContactsStore((s) => s.fetchAddressBooks);
const fetchContacts = useContactsStore((s) => s.fetchContacts);
const fetchContactsIfStale = useContactsStore((s) => s.fetchContactsIfStale);
const hydrate = useContactsStore((s) => s.hydrate);
const bulkDelete = useContactsStore((s) => s.bulkDelete);
const moveContactsToAddressBook = useContactsStore((s) => s.moveContactsToAddressBook);
@@ -130,10 +131,12 @@ export default function ContactsScreen() {
void hydrate();
}, [hydrate]);
// The cards loaded at startup and follow live changes; only download them
// again when that copy is stale. Pull to refresh still forces a reload.
React.useEffect(() => {
void fetchAddressBooks();
void fetchContacts();
}, [fetchAddressBooks, fetchContacts]);
void fetchContactsIfStale();
}, [fetchAddressBooks, fetchContactsIfStale]);
const groups = React.useMemo(() => contacts.filter(isGroup), [contacts]);
const individuals = React.useMemo(() => contacts.filter((c) => !isGroup(c)), [contacts]);
+47 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/contacts', () => ({
getAddressBooks: vi.fn(),
@@ -53,6 +53,7 @@ import {
selectGroupMembers,
sortContactsByName,
selectCreateTargetBookId,
CONTACTS_STALE_MS,
} from '../contacts-store';
import type { AddressBook, ContactCard } from '../../api/types';
@@ -144,6 +145,51 @@ describe('contacts-store', () => {
});
});
describe('fetchContactsIfStale (PF6)', () => {
afterEach(() => {
vi.useRealTimers();
});
it('does not download the cards again while the last full fetch is fresh', async () => {
vi.useFakeTimers();
mockGetAllContacts.mockResolvedValue([card('c1')]);
await useContactsStore.getState().fetchContacts();
expect(mockGetAllContacts).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(CONTACTS_STALE_MS - 1000);
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(2000);
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(2);
});
it('fetches when nothing was loaded this session, after a reset or after a filtered load', async () => {
mockGetAllContacts.mockResolvedValue([card('c1')]);
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(1);
useContactsStore.getState().reset();
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(2);
mockQueryContacts.mockResolvedValue([]);
await useContactsStore.getState().fetchContacts({ text: 'x' });
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(3);
});
it('retries on the next open when the last fetch failed', async () => {
mockGetAllContacts.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([card('c1')]);
await useContactsStore.getState().fetchContactsIfStale();
expect(useContactsStore.getState().contactsFetchedAt).toBe(0);
await useContactsStore.getState().fetchContactsIfStale();
expect(mockGetAllContacts).toHaveBeenCalledTimes(2);
expect(useContactsStore.getState().contacts).toHaveLength(1);
});
});
describe('createContact', () => {
it('should create and append to list', async () => {
useContactsStore.setState({ contacts: [card('c1')], addressBooks: [{ id: 'ab-1', name: 'P' }] });
+22 -2
View File
@@ -61,6 +61,8 @@ export interface ContactsState {
loading: boolean;
error: string | null;
hydrated: boolean;
/** When `contacts` last got every card from the server (0 = not this session). */
contactsFetchedAt: number;
// Trusted senders are stored as contacts in a dedicated JMAP address book so
// the allow-list syncs across devices (matches the webmail behavior).
@@ -78,6 +80,8 @@ export interface ContactsState {
hydrate: () => Promise<void>;
fetchAddressBooks: () => Promise<void>;
fetchContacts: (filter?: { text?: string; inAddressBook?: string }) => Promise<void>;
/** `fetchContacts()` unless every card was fetched within `CONTACTS_STALE_MS`. */
fetchContactsIfStale: () => Promise<void>;
refresh: () => Promise<void>;
handleStateChange: (change: StateChange) => Promise<void>;
@@ -225,6 +229,12 @@ let addressBooksPromise: Promise<void> | null = null;
let contactsPromise: Promise<void> | null = null;
let trustedSendersPromise: Promise<void> | null = null;
// Startup fetches every card and live `ContactCard` state changes refetch
// them, so opening Contacts only downloads the cards again once the last full
// fetch is this old - it covers changes missed while the app was in the
// background or live updates were down (PF6).
export const CONTACTS_STALE_MS = 5 * 60_000;
export const useContactsStore = create<ContactsState>()(
persist(
(set, get) => {
@@ -292,6 +302,7 @@ export const useContactsStore = create<ContactsState>()(
loading: false,
error: null,
hydrated: false,
contactsFetchedAt: 0,
trustedSendersBookId: null,
trustedSenderEmails: [],
@@ -340,13 +351,15 @@ export const useContactsStore = create<ContactsState>()(
set({ loading: true, error: null });
try {
let contacts: ContactCard[];
if (filter && Object.keys(filter).length > 0) {
const filtered = !!filter && Object.keys(filter).length > 0;
if (filtered) {
const ids = (await queryContacts(filter)) ?? [];
contacts = ids.length > 0 ? ((await fetchContactsByIds(ids)) ?? []) : [];
} else {
contacts = (await fetchAllContacts()) ?? [];
}
set({ contacts, loading: false });
// A filtered load leaves only some of the cards in state.
set({ contacts, loading: false, contactsFetchedAt: filtered ? 0 : Date.now() });
} catch (err) {
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' });
}
@@ -356,6 +369,12 @@ export const useContactsStore = create<ContactsState>()(
return contactsPromise;
},
fetchContactsIfStale: async () => {
const fetchedAt = get().contactsFetchedAt;
if (fetchedAt > 0 && Date.now() - fetchedAt < CONTACTS_STALE_MS) return;
await get().fetchContacts();
},
refresh: async () => {
await get().fetchContacts();
},
@@ -807,6 +826,7 @@ export const useContactsStore = create<ContactsState>()(
selectedCategory: { type: 'all' },
loading: false,
error: null,
contactsFetchedAt: 0,
trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,