account-state-manager had two latent correctness issues: 1. Shared references: snapshotAccount stored the live store arrays/objects directly, so a later in-place mutation (array push/splice, or a shared email object being stamped) retroactively corrupted an earlier snapshot. Now copies the captured collections. 2. Incomplete restore: the snapshot only captures a subset of each store's fields, but restoreAccount applied it with a merge, leaving every other field (email selection, loading flags, tag counts, …) at the previously active account's values. It only worked because every caller happened to call clearAllStores() first. restoreAccount now resets the stores to baseline itself before layering the snapshot back on, so it is correct standalone and can't leak state across accounts. Adds tests pinning the isolation guarantees.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
/**
|
|
* Manages per-account state snapshots for fast switching.
|
|
* When user switches from Account A → B, we snapshot A's store state
|
|
* into memory, clear stores, then restore B's cached state.
|
|
*/
|
|
|
|
import { useEmailStore } from '@/stores/email-store';
|
|
import { useContactStore } from '@/stores/contact-store';
|
|
import { useCalendarStore } from '@/stores/calendar-store';
|
|
import { useFilterStore } from '@/stores/filter-store';
|
|
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
|
import { useIdentityStore } from '@/stores/identity-store';
|
|
import { useVacationStore } from '@/stores/vacation-store';
|
|
import { useSmimeStore } from '@/stores/smime-store';
|
|
|
|
// Minimal snapshot shapes - we only capture what we need
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type StoreSnapshot = Record<string, any>;
|
|
|
|
interface AccountSnapshot {
|
|
email: StoreSnapshot;
|
|
contact: StoreSnapshot;
|
|
calendar: StoreSnapshot;
|
|
filter: StoreSnapshot;
|
|
identity: StoreSnapshot;
|
|
vacation: StoreSnapshot;
|
|
}
|
|
|
|
const cache = new Map<string, AccountSnapshot>();
|
|
|
|
/** Capture current store states for the given account */
|
|
export function snapshotAccount(accountId: string): void {
|
|
const emailState = useEmailStore.getState();
|
|
const contactState = useContactStore.getState();
|
|
const calendarState = useCalendarStore.getState();
|
|
const filterState = useFilterStore.getState();
|
|
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],
|
|
selectedEmail: emailState.selectedEmail,
|
|
selectedMailbox: emailState.selectedMailbox,
|
|
searchQuery: emailState.searchQuery,
|
|
quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
|
|
},
|
|
contact: {
|
|
contacts: [...contactState.contacts],
|
|
addressBooks: [...contactState.addressBooks],
|
|
supportsSync: contactState.supportsSync,
|
|
},
|
|
calendar: {
|
|
calendars: [...calendarState.calendars],
|
|
events: [...calendarState.events],
|
|
selectedCalendarIds: [...calendarState.selectedCalendarIds],
|
|
viewMode: calendarState.viewMode,
|
|
supportsCalendar: calendarState.supportsCalendar,
|
|
},
|
|
filter: {
|
|
rules: [...filterState.rules],
|
|
isSupported: filterState.isSupported,
|
|
},
|
|
identity: {
|
|
identities: [...identityState.identities],
|
|
preferredPrimaryId: identityState.preferredPrimaryId,
|
|
},
|
|
vacation: {
|
|
isEnabled: vacationState.isEnabled,
|
|
isSupported: vacationState.isSupported,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
useFilterStore.setState(snapshot.filter);
|
|
useIdentityStore.setState(snapshot.identity);
|
|
useVacationStore.setState(snapshot.vacation);
|
|
|
|
return true;
|
|
}
|
|
|
|
/** Clear all stores (used before restoring a different account) */
|
|
export function clearAllStores(): void {
|
|
useEmailStore.setState({
|
|
emails: [],
|
|
mailboxes: [],
|
|
selectedEmail: null,
|
|
selectedMailbox: '',
|
|
isLoading: false,
|
|
error: null,
|
|
searchQuery: '',
|
|
quota: null,
|
|
isPushConnected: false,
|
|
lastPushUpdate: null,
|
|
newEmailNotification: null,
|
|
selectedEmailIds: new Set<string>(),
|
|
hasMoreEmails: false,
|
|
totalEmails: 0,
|
|
expandedThreadIds: new Set<string>(),
|
|
threadEmailsCache: new Map(),
|
|
isLoadingThread: null,
|
|
selectedKeyword: null,
|
|
tagCounts: {},
|
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
|
isAdvancedSearchOpen: false,
|
|
});
|
|
useIdentityStore.getState().clearIdentities();
|
|
useContactStore.getState().clearContacts();
|
|
useVacationStore.getState().clearState();
|
|
useCalendarStore.getState().clearState();
|
|
useFilterStore.getState().clearState();
|
|
useSmimeStore.getState().clearState();
|
|
}
|
|
|
|
/** Evict cached state for one account */
|
|
export function evictAccount(accountId: string): void {
|
|
cache.delete(accountId);
|
|
}
|
|
|
|
/** Evict all cached states */
|
|
export function evictAll(): void {
|
|
cache.clear();
|
|
}
|