diff --git a/lib/__tests__/account-state-manager.test.ts b/lib/__tests__/account-state-manager.test.ts index 658fe2c5..7f61265f 100644 --- a/lib/__tests__/account-state-manager.test.ts +++ b/lib/__tests__/account-state-manager.test.ts @@ -58,7 +58,7 @@ describe('snapshotAccount / restoreAccount', () => { expect(useVacationStore.getState().isEnabled).toBe(true); }); - it('CHARACTERISATION: only snapshotted fields are restored; others survive', () => { + it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => { // isLoading is NOT part of the email snapshot subset. useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false }); snapshotAccount('A'); @@ -66,11 +66,11 @@ describe('snapshotAccount / restoreAccount', () => { restoreAccount('A'); - expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // restored - expect(useEmailStore.getState().isLoading).toBe(true); // NOT restored (merge) + expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored + expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked }); - it('CHARACTERISATION: snapshot stores array references, not deep clones', () => { + it('decouples the snapshot from later in-place mutation of the source array', () => { const arr = [makeEmail({ id: '1' })]; useEmailStore.setState({ emails: arr }); snapshotAccount('A'); @@ -78,8 +78,8 @@ describe('snapshotAccount / restoreAccount', () => { useEmailStore.setState({ emails: [] }); restoreAccount('A'); - // The post-snapshot mutation leaked into the snapshot. - expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1', '2']); + // The post-snapshot mutation did NOT leak into the snapshot. + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']); }); it('returns false and leaves stores untouched for an unknown account', () => { diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 7fba8a3d..10dc9fe8 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -37,33 +37,37 @@ export function snapshotAccount(accountId: string): void { const identityState = useIdentityStore.getState(); const vacationState = useVacationStore.getState(); + // Copy the captured collections so the snapshot is decoupled from the live + // store: a later in-place mutation (e.g. an array push/splice, or stamping + // fields onto a shared email object) must not retroactively corrupt a + // snapshot taken earlier. cache.set(accountId, { email: { - emails: emailState.emails, - mailboxes: emailState.mailboxes, + emails: [...emailState.emails], + mailboxes: [...emailState.mailboxes], selectedEmail: emailState.selectedEmail, selectedMailbox: emailState.selectedMailbox, searchQuery: emailState.searchQuery, - quota: emailState.quota, + quota: emailState.quota ? { ...emailState.quota } : emailState.quota, }, contact: { - contacts: contactState.contacts, - addressBooks: contactState.addressBooks, + contacts: [...contactState.contacts], + addressBooks: [...contactState.addressBooks], supportsSync: contactState.supportsSync, }, calendar: { - calendars: calendarState.calendars, - events: calendarState.events, - selectedCalendarIds: calendarState.selectedCalendarIds, + calendars: [...calendarState.calendars], + events: [...calendarState.events], + selectedCalendarIds: [...calendarState.selectedCalendarIds], viewMode: calendarState.viewMode, supportsCalendar: calendarState.supportsCalendar, }, filter: { - rules: filterState.rules, + rules: [...filterState.rules], isSupported: filterState.isSupported, }, identity: { - identities: identityState.identities, + identities: [...identityState.identities], preferredPrimaryId: identityState.preferredPrimaryId, }, vacation: { @@ -73,11 +77,22 @@ export function snapshotAccount(accountId: string): void { }); } -/** Restore cached store states for the given account. Returns false if no cache exists. */ +/** + * Restore cached store states for the given account. Returns false if no cache + * exists. + * + * The snapshot only captures a subset of each store's fields (the loaded data), + * so we reset every store to its baseline first. Without this, fields outside + * the captured subset (e.g. email selection, loading flags, tag counts) would + * carry over from whatever account was active, leaking state across accounts. + * `setState` merges, so the captured fields are then layered back on top. + */ export function restoreAccount(accountId: string): boolean { const snapshot = cache.get(accountId); if (!snapshot) return false; + clearAllStores(); + useEmailStore.setState(snapshot.email); useContactStore.setState(snapshot.contact); useCalendarStore.setState(snapshot.calendar);