fix: keep contacts loaded or saved for one account out of the next after a switch

This commit is contained in:
Linus Rath
2026-10-29 03:10:37 +01:00
parent df1a5ab4b6
commit 6b9552fcb4
2 changed files with 172 additions and 31 deletions
@@ -597,6 +597,61 @@ describe('contacts-store', () => {
}); });
}); });
describe('an account switch while contacts load or save', () => {
function deferred<T>() {
let resolve: (v: T) => void = () => {};
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
it('drops the previous account\'s cards that arrive after the switch', async () => {
const old = deferred<ContactCard[]>();
mockGetAllContacts.mockReturnValueOnce(old.promise);
const oldLoad = useContactsStore.getState().fetchContacts();
useContactsStore.getState().reset();
mockGetAllContacts.mockResolvedValueOnce([card('new-1')]);
// Not handed the old account's load still in flight.
await useContactsStore.getState().fetchContacts();
expect(mockGetAllContacts).toHaveBeenCalledTimes(2);
old.resolve([card('old-1')]);
await oldLoad;
expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['new-1']);
});
it('drops the previous account\'s address books', async () => {
const old = deferred<AddressBook[]>();
mockGetAllAddressBooks.mockReturnValueOnce(old.promise);
const oldLoad = useContactsStore.getState().fetchAddressBooks();
useContactsStore.getState().reset();
old.resolve([{ id: 'ab-old', name: 'Old' } as AddressBook]);
await oldLoad;
expect(useContactsStore.getState().addressBooks).toEqual([]);
});
it('keeps a card created for the previous account out of the next one', async () => {
const write = deferred<ContactCard>();
mockCreateContact.mockReturnValueOnce(write.promise);
const create = useContactsStore.getState().createContact({}, 'ab-1');
useContactsStore.getState().reset();
write.resolve(card('created-before'));
await expect(create).rejects.toThrow(/account changed/);
expect(useContactsStore.getState().contacts).toEqual([]);
});
it('stops an import at the switch instead of writing into the next account', async () => {
const first = deferred<ContactCard>();
mockCreateContact.mockReturnValueOnce(first.promise);
const run = useContactsStore.getState().importContacts([{ id: 'a' }, { id: 'b' }, { id: 'c' }], 'ab-1');
useContactsStore.getState().reset();
first.resolve(card('imported-a'));
await expect(run).rejects.toThrow(/account changed/);
expect(mockCreateContact).toHaveBeenCalledTimes(1);
expect(useContactsStore.getState().contacts).toEqual([]);
});
});
describe('persistence', () => { describe('persistence', () => {
it('hands the storage the same slice while the cards are unchanged', () => { it('hands the storage the same slice while the cards are unchanged', () => {
const partialize = useContactsStore.persist.getOptions().partialize!; const partialize = useContactsStore.persist.getOptions().partialize!;
+117 -31
View File
@@ -240,6 +240,34 @@ let addressBooksPromise: Promise<void> | null = null;
let contactsPromise: Promise<void> | null = null; let contactsPromise: Promise<void> | null = null;
let trustedSendersPromise: Promise<void> | null = null; let trustedSendersPromise: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out), as in the calendar
// store. The client is one singleton that serves whichever account is active,
// so a load or a write still under way from before must keep what it read to
// itself: landing after the switch, it put the previous account's cards and
// books under the next one, and a loop of writes went on in the new account.
let storeGeneration = 0;
/** The store was reset (another account) while a contacts write was under way. */
export class ContactsAccountChangedError extends Error {
constructor() {
super('The account changed while the contacts were being saved');
this.name = 'ContactsAccountChangedError';
}
}
/** For one load or write: whether a reset has overtaken it since it started. */
function generationGuard() {
const generation = storeGeneration;
const stale = () => generation !== storeGeneration;
return {
stale,
/** Throws ContactsAccountChangedError once stale: nothing more may be sent or kept. */
check: () => {
if (stale()) throw new ContactsAccountChangedError();
},
};
}
// Startup fetches every card and live `ContactCard` state changes refetch // Startup fetches every card and live `ContactCard` state changes refetch
// them, so opening Contacts only downloads the cards again once the last full // 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 // fetch is this old - it covers changes missed while the app was in the
@@ -298,10 +326,13 @@ export const useContactsStore = create<ContactsState>()(
}; };
const loadAddressBooks = async (): Promise<void> => { const loadAddressBooks = async (): Promise<void> => {
const { stale } = generationGuard();
try { try {
const addressBooks = (await fetchAllAddressBooks()) ?? []; const addressBooks = (await fetchAllAddressBooks()) ?? [];
if (stale()) return;
set({ addressBooks }); set({ addressBooks });
} catch (err) { } catch (err) {
if (stale()) return;
set({ error: err instanceof Error ? err.message : 'Failed to load address books' }); set({ error: err instanceof Error ? err.message : 'Failed to load address books' });
} }
}; };
@@ -346,19 +377,23 @@ export const useContactsStore = create<ContactsState>()(
// has set up the client. // has set up the client.
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
if (addressBooksPromise) return addressBooksPromise; if (addressBooksPromise) return addressBooksPromise;
addressBooksPromise = loadAddressBooks().finally(() => { const promise = loadAddressBooks().finally(() => {
addressBooksPromise = null; // A reset may have handed the slot to the next account's load.
if (addressBooksPromise === promise) addressBooksPromise = null;
}); });
return addressBooksPromise; addressBooksPromise = promise;
return promise;
}, },
fetchContacts: async (filter) => { fetchContacts: async (filter) => {
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
if (contactsPromise) return contactsPromise; if (contactsPromise) return contactsPromise;
contactsPromise = (async () => { const { stale } = generationGuard();
const promise: Promise<void> = (async () => {
// Books first: AddressBook/get is the call that makes Stalwart mint // Books first: AddressBook/get is the call that makes Stalwart mint
// the default book, so ContactCard/query must never run alongside it. // the default book, so ContactCard/query must never run alongside it.
if (addressBooksPromise) await addressBooksPromise; if (addressBooksPromise) await addressBooksPromise;
if (stale()) return;
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
let contacts: ContactCard[]; let contacts: ContactCard[];
@@ -369,15 +404,18 @@ export const useContactsStore = create<ContactsState>()(
} else { } else {
contacts = (await fetchAllContacts()) ?? []; contacts = (await fetchAllContacts()) ?? [];
} }
if (stale()) return;
// A filtered load leaves only some of the cards in state. // A filtered load leaves only some of the cards in state.
set({ contacts, loading: false, contactsFetchedAt: filtered ? 0 : Date.now() }); set({ contacts, loading: false, contactsFetchedAt: filtered ? 0 : Date.now() });
} catch (err) { } catch (err) {
if (stale()) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' }); set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load contacts' });
} }
})().finally(() => { })().finally(() => {
contactsPromise = null; if (contactsPromise === promise) contactsPromise = null;
}); });
return contactsPromise; contactsPromise = promise;
return promise;
}, },
fetchContactsIfStale: async () => { fetchContactsIfStale: async () => {
@@ -411,12 +449,15 @@ export const useContactsStore = create<ContactsState>()(
}, },
createContact: async (contact, addressBookId, options) => { createContact: async (contact, addressBookId, options) => {
const { check } = generationGuard();
const { originalId, accountId, book } = bookTarget(addressBookId); const { originalId, accountId, book } = bookTarget(addressBookId);
const write = (card: Partial<ContactCard>) => apiCreateContact(card, originalId, accountId); // The photo goes up first; the card must not follow it into the next account.
const write = (card: Partial<ContactCard>) => { check(); return apiCreateContact(card, originalId, accountId); };
const saved = options?.photoKey const saved = options?.photoKey
? await writeContactWithNewPhoto(accountId, contact, options.photoKey, write) ? await writeContactWithNewPhoto(accountId, contact, options.photoKey, write)
: await write(contact); : await write(contact);
const created = tagCreated(saved, book); const created = tagCreated(saved, book);
check();
set({ contacts: [...get().contacts, created] }); set({ contacts: [...get().contacts, created] });
requestDeviceSync(CONTACTS_AUTHORITY); requestDeviceSync(CONTACTS_AUTHORITY);
return created; return created;
@@ -426,12 +467,14 @@ export const useContactsStore = create<ContactsState>()(
const contact = get().contacts.find((c) => c.id === id); const contact = get().contacts.find((c) => c.id === id);
const { originalId, accountId } = contactTarget(id); const { originalId, accountId } = contactTarget(id);
const patch = cleanPatch(contact, changes); const patch = cleanPatch(contact, changes);
const write = (card: Partial<ContactCard>) => apiUpdateContact(originalId, card, accountId); const { check } = generationGuard();
const write = (card: Partial<ContactCard>) => { check(); return apiUpdateContact(originalId, card, accountId); };
if (options?.photoKey) { if (options?.photoKey) {
await writeContactWithNewPhoto(accountId, patch, options.photoKey, write); await writeContactWithNewPhoto(accountId, patch, options.photoKey, write);
} else { } else {
await write(patch); await write(patch);
} }
check();
set({ set({
contacts: get().contacts.map((c) => { contacts: get().contacts.map((c) => {
if (c.id !== id) return c; if (c.id !== id) return c;
@@ -459,8 +502,10 @@ export const useContactsStore = create<ContactsState>()(
}, },
deleteContact: async (id) => { deleteContact: async (id) => {
const { check } = generationGuard();
const { originalId, accountId } = contactTarget(id); const { originalId, accountId } = contactTarget(id);
await apiDeleteContacts([originalId], accountId); await apiDeleteContacts([originalId], accountId);
check();
set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) }); set({ contacts: cleanGroupMembers(get().contacts, new Set([id])).filter((c) => c.id !== id) });
requestDeviceSync(CONTACTS_AUTHORITY); requestDeviceSync(CONTACTS_AUTHORITY);
}, },
@@ -477,7 +522,9 @@ export const useContactsStore = create<ContactsState>()(
} }
const removed = new Set<string>(); const removed = new Set<string>();
let failed = 0; let failed = 0;
const { check } = generationGuard();
for (const [accountId, entries] of byAccount) { for (const [accountId, entries] of byAccount) {
check();
try { try {
const destroyed = new Set( const destroyed = new Set(
await apiDeleteContacts(entries.map((e) => e.originalId), accountId), await apiDeleteContacts(entries.map((e) => e.originalId), accountId),
@@ -491,6 +538,7 @@ export const useContactsStore = create<ContactsState>()(
failed += entries.length; failed += entries.length;
} }
} }
check();
set({ set({
contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)), contacts: cleanGroupMembers(get().contacts, removed).filter((c) => !removed.has(c.id)),
...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}), ...(failed > 0 ? { error: `Failed to delete ${failed} contact${failed === 1 ? '' : 's'}` } : {}),
@@ -502,17 +550,22 @@ export const useContactsStore = create<ContactsState>()(
let imported = 0; let imported = 0;
let failed = 0; let failed = 0;
const { originalId, accountId, book } = bookTarget(addressBookId); const { originalId, accountId, book } = bookTarget(addressBookId);
const { check } = generationGuard();
for (const contact of incoming) { for (const contact of incoming) {
check();
let created: ContactCard;
try { try {
// Strip the temporary client-side id; the server assigns a real one. // Strip the temporary client-side id; the server assigns a real one.
const { id: _id, addressBookIds: _abIds, ...data } = contact; const { id: _id, addressBookIds: _abIds, ...data } = contact;
const created = tagCreated(await apiCreateContact(data, originalId, accountId), book); created = tagCreated(await apiCreateContact(data, originalId, accountId), book);
set({ contacts: [...get().contacts, created] });
imported++;
} catch (err) { } catch (err) {
failed++; failed++;
console.warn('[contacts-store] import failed for one contact', err); console.warn('[contacts-store] import failed for one contact', err);
continue;
} }
check();
set({ contacts: [...get().contacts, created] });
imported++;
} }
if (imported > 0) requestDeviceSync(CONTACTS_AUTHORITY); if (imported > 0) requestDeviceSync(CONTACTS_AUTHORITY);
return { imported, failed }; return { imported, failed };
@@ -561,13 +614,16 @@ export const useContactsStore = create<ContactsState>()(
const target = bookTarget(addressBookId); const target = bookTarget(addressBookId);
const primaryAccountId = getContactsAccountId(); const primaryAccountId = getContactsAccountId();
const targetAccount = target.accountId || primaryAccountId; const targetAccount = target.accountId || primaryAccountId;
const { check } = generationGuard();
for (const id of ids) { for (const id of ids) {
check();
const contact = get().contacts.find((c) => c.id === id); const contact = get().contacts.find((c) => c.id === id);
if (!contact) continue; if (!contact) continue;
const source = contactTarget(id); const source = contactTarget(id);
const sourceAccount = source.accountId || primaryAccountId; const sourceAccount = source.accountId || primaryAccountId;
if (sourceAccount === targetAccount) { if (sourceAccount === targetAccount) {
await apiUpdateContact(source.originalId, { addressBookIds: { [target.originalId]: true } }, source.accountId); await apiUpdateContact(source.originalId, { addressBookIds: { [target.originalId]: true } }, source.accountId);
check();
set({ set({
contacts: get().contacts.map((c) => contacts: get().contacts.map((c) =>
c.id === id ? { ...c, addressBookIds: { [addressBookId]: true } } : c, c.id === id ? { ...c, addressBookIds: { [addressBookId]: true } } : c,
@@ -583,7 +639,9 @@ export const useContactsStore = create<ContactsState>()(
// successful create. // successful create.
const media = await inlineContactMedia(contact, 'keep'); const media = await inlineContactMedia(contact, 'keep');
if (media) data.media = media; if (media) data.media = media;
check();
const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book); const created = tagCreated(await apiCreateContact(data, target.originalId, target.accountId), target.book);
check();
await apiDeleteContacts([source.originalId], source.accountId); await apiDeleteContacts([source.originalId], source.accountId);
set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) }); set({ contacts: get().contacts.map((c) => (c.id === id ? created : c)) });
} }
@@ -645,7 +703,9 @@ export const useContactsStore = create<ContactsState>()(
createAddressBook: async (name) => { createAddressBook: async (name) => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) throw new Error('Address book name is required'); if (!trimmed) throw new Error('Address book name is required');
const { check } = generationGuard();
const book = await apiCreateAddressBook(trimmed); const book = await apiCreateAddressBook(trimmed);
check();
set({ addressBooks: [...get().addressBooks, book] }); set({ addressBooks: [...get().addressBooks, book] });
requestDeviceSync(CONTACTS_AUTHORITY); requestDeviceSync(CONTACTS_AUTHORITY);
return book; return book;
@@ -654,8 +714,10 @@ export const useContactsStore = create<ContactsState>()(
renameAddressBook: async (id, name) => { renameAddressBook: async (id, name) => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return; if (!trimmed) return;
const { check } = generationGuard();
const { originalId, accountId } = bookTarget(id); const { originalId, accountId } = bookTarget(id);
await apiUpdateAddressBook(originalId, { name: trimmed }, accountId); await apiUpdateAddressBook(originalId, { name: trimmed }, accountId);
check();
set({ set({
addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)), addressBooks: get().addressBooks.map((b) => (b.id === id ? { ...b, name: trimmed } : b)),
}); });
@@ -663,8 +725,10 @@ export const useContactsStore = create<ContactsState>()(
}, },
deleteAddressBook: async (id) => { deleteAddressBook: async (id) => {
const { check } = generationGuard();
const { originalId, accountId } = bookTarget(id); const { originalId, accountId } = bookTarget(id);
await apiDeleteAddressBook(originalId, accountId); await apiDeleteAddressBook(originalId, accountId);
check();
// Stalwart destroys the cards filed in the book along with it, so // Stalwart destroys the cards filed in the book along with it, so
// drop them from the cache instead of leaving orphans under "All". // drop them from the cache instead of leaving orphans under "All".
set({ set({
@@ -679,8 +743,10 @@ export const useContactsStore = create<ContactsState>()(
}, },
setDefaultAddressBook: async (id) => { setDefaultAddressBook: async (id) => {
const { check } = generationGuard();
const { originalId, accountId, book } = bookTarget(id); const { originalId, accountId, book } = bookTarget(id);
await apiSetDefaultAddressBook(originalId, accountId); await apiSetDefaultAddressBook(originalId, accountId);
check();
set({ set({
addressBooks: get().addressBooks.map((b) => { addressBooks: get().addressBooks.map((b) => {
if (b.id === id) return { ...b, isDefault: true }; if (b.id === id) return { ...b, isDefault: true };
@@ -704,12 +770,14 @@ export const useContactsStore = create<ContactsState>()(
if (sentMailboxId) set({ sentMailboxId }); if (sentMailboxId) set({ sentMailboxId });
const mailboxId = get().sentMailboxId; const mailboxId = get().sentMailboxId;
if (get().recentRecipientsLoaded || !mailboxId || !jmapClient.isConnected) return; if (get().recentRecipientsLoaded || !mailboxId || !jmapClient.isConnected) return;
const { stale } = generationGuard();
try { try {
const recentRecipients = await queryRecentRecipients(mailboxId, 300); const recentRecipients = await queryRecentRecipients(mailboxId, 300);
if (stale()) return;
set({ recentRecipients, recentRecipientsLoaded: true }); set({ recentRecipients, recentRecipientsLoaded: true });
} catch (err) { } catch (err) {
console.warn('[contacts-store] load recent recipients failed', err); console.warn('[contacts-store] load recent recipients failed', err);
set({ recentRecipientsLoaded: true }); if (!stale()) set({ recentRecipientsLoaded: true });
} }
}, },
@@ -791,7 +859,8 @@ export const useContactsStore = create<ContactsState>()(
if (get().trustedSendersLoaded && !createIfMissing) return; if (get().trustedSendersLoaded && !createIfMissing) return;
set({ trustedSendersLoading: true }); set({ trustedSendersLoading: true });
trustedSendersPromise = (async () => { const { stale } = generationGuard();
const promise: Promise<void> = (async () => {
try { try {
// Must throw rather than yield [] on failure: treating a failed // Must throw rather than yield [] on failure: treating a failed
// fetch as "no book yet" minted a duplicate on every hiccup (#730). // fetch as "no book yet" minted a duplicate on every hiccup (#730).
@@ -801,15 +870,18 @@ export const useContactsStore = create<ContactsState>()(
.filter((b) => b.name === TRUSTED_SENDERS_BOOK_NAME) .filter((b) => b.name === TRUSTED_SENDERS_BOOK_NAME)
.sort((a, b) => a.id.localeCompare(b.id)); .sort((a, b) => a.id.localeCompare(b.id));
let book = matches[0]; let book = matches[0];
if (stale()) return;
if (!book) { if (!book) {
if (!createIfMissing) { if (!createIfMissing) {
set({ trustedSendersLoaded: true, trustedSendersLoading: false }); set({ trustedSendersLoaded: true, trustedSendersLoading: false });
return; return;
} }
book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME); book = await apiCreateAddressBook(TRUSTED_SENDERS_BOOK_NAME);
if (stale()) return;
} }
const bookId = book.id; const bookId = book.id;
const cards = await getContactsInBook(bookId); const cards = await getContactsInBook(bookId);
if (stale()) return;
set({ set({
trustedSendersBookId: bookId, trustedSendersBookId: bookId,
trustedSenderEmails: collectContactEmails(cards), trustedSenderEmails: collectContactEmails(cards),
@@ -818,12 +890,13 @@ export const useContactsStore = create<ContactsState>()(
}); });
} catch (err) { } catch (err) {
console.warn('[contacts-store] load trusted senders failed', err); console.warn('[contacts-store] load trusted senders failed', err);
set({ trustedSendersLoaded: true, trustedSendersLoading: false }); if (!stale()) set({ trustedSendersLoaded: true, trustedSendersLoading: false });
} }
})().finally(() => { })().finally(() => {
trustedSendersPromise = null; if (trustedSendersPromise === promise) trustedSendersPromise = null;
}); });
return trustedSendersPromise; trustedSendersPromise = promise;
return promise;
}, },
addToTrustedSendersBook: async (input) => { addToTrustedSendersBook: async (input) => {
@@ -839,6 +912,7 @@ export const useContactsStore = create<ContactsState>()(
// The load may have found the address already filed in the book. // The load may have found the address already filed in the book.
if (get().trustedSenderEmails.includes(email)) return; if (get().trustedSenderEmails.includes(email)) return;
const { check } = generationGuard();
await apiCreateContact( await apiCreateContact(
{ {
...(name ? { name: { full: name } } : {}), ...(name ? { name: { full: name } } : {}),
@@ -846,6 +920,7 @@ export const useContactsStore = create<ContactsState>()(
}, },
bookId, bookId,
); );
check();
set({ trustedSenderEmails: [...get().trustedSenderEmails, email] }); set({ trustedSenderEmails: [...get().trustedSenderEmails, email] });
requestDeviceSync(CONTACTS_AUTHORITY); requestDeviceSync(CONTACTS_AUTHORITY);
}, },
@@ -854,10 +929,13 @@ export const useContactsStore = create<ContactsState>()(
const { email } = parseTrustedSenderInput(input); const { email } = parseTrustedSenderInput(input);
const bookId = get().trustedSendersBookId; const bookId = get().trustedSendersBookId;
if (!bookId || !email) return; if (!bookId || !email) return;
const { check } = generationGuard();
const cards = await getContactsInBook(bookId); const cards = await getContactsInBook(bookId);
check();
const matches = cards.filter((c) => const matches = cards.filter((c) =>
c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email)); c.emails && Object.values(c.emails).some((e) => e.address?.toLowerCase().trim() === email));
if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id)); if (matches.length > 0) await apiDeleteContacts(matches.map((m) => m.id));
check();
set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) }); set({ trustedSenderEmails: get().trustedSenderEmails.filter((e) => e !== email) });
if (matches.length > 0) requestDeviceSync(CONTACTS_AUTHORITY); if (matches.length > 0) requestDeviceSync(CONTACTS_AUTHORITY);
}, },
@@ -870,21 +948,29 @@ export const useContactsStore = create<ContactsState>()(
persistCategory(category); persistCategory(category);
}, },
reset: () => set({ reset: () => {
addressBooks: [], storeGeneration += 1;
contacts: [], // The next account's first load must not join one still under way
selectedCategory: { type: 'all' }, // for this one.
loading: false, addressBooksPromise = null;
error: null, contactsPromise = null;
contactsFetchedAt: 0, trustedSendersPromise = null;
trustedSendersBookId: null, set({
trustedSenderEmails: [], addressBooks: [],
trustedSendersLoaded: false, contacts: [],
trustedSendersLoading: false, selectedCategory: { type: 'all' },
recentRecipients: [], loading: false,
recentRecipientsLoaded: false, error: null,
sentMailboxId: null, contactsFetchedAt: 0,
}), trustedSendersBookId: null,
trustedSenderEmails: [],
trustedSendersLoaded: false,
trustedSendersLoading: false,
recentRecipients: [],
recentRecipientsLoaded: false,
sentMailboxId: null,
});
},
}; };
}, },
{ {