diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index 6f23568b..f24db0f1 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'acc-1', + isConnected: true, }, })); diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index cc63f4cd..8390926f 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ vi.mock('../../api/jmap-client', () => ({ jmapClient: { accountId: 'acc-1', + isConnected: true, }, })); diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 4417b167..5f202a43 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -17,7 +17,18 @@ vi.mock('../../api/email', () => ({ // trip on react-native's Flow-typed entrypoint under vitest. The store only // reads archiveMode in archiveEmail, so a minimal stub is enough. vi.mock('../settings-store', () => ({ - useSettingsStore: { getState: () => ({ archiveMode: 'single' }) }, + useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) }, +})); + +// email-store now early-returns from fetch actions when jmapClient.isConnected +// is false (cold-start guard). The tests exercise those actions, so present +// a connected stub. +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + isConnected: true, + accountId: 'acc-1', + currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, + }, })); import * as emailApi from '../../api/email'; diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index b979728f..86823a95 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -225,17 +225,42 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }); - jmapClient.reset(); - resetFeatureStores(); - - const ok = await jmapClient.loadAccount(accountId); - if (!ok) { - // Credentials missing - evict stale entry and surface error - accountStore.removeAccount(accountId); - set({ isLoading: false, error: 'Session expired for this account' }); + // Load the new account's session BEFORE clearing jmapClient or feature + // stores. The previous order (reset → resetFeatureStores → loadAccount) + // left jmapClient with no accountId during the loadAccount await, and + // any fetch fired by a screen effect reacting to the emptied stores + // (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to + // []) threw "Not authenticated - call connect() first". loadAccount + // overwrites credentials/session/_accountId itself, so the pre-reset + // is unnecessary. + try { + const ok = await jmapClient.loadAccount(accountId); + if (!ok) { + // Credentials missing - evict stale entry and surface error + accountStore.removeAccount(accountId); + set({ isLoading: false, error: 'Session expired for this account' }); + return; + } + } catch (err) { + if (err instanceof AuthenticationError) { + await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); + accountStore.removeAccount(accountId); + set({ isLoading: false, error: 'Session expired for this account' }); + return; + } + // NetworkError or anything else - keep the previous active account + // intact instead of stranding the user on a half-switched state. + set({ + isLoading: false, + error: err instanceof Error ? err.message : 'Failed to switch account', + }); return; } + // Session is in place under the new account. Safe now to wipe the + // previous account's cached feature data; the refetch below repopulates. + resetFeatureStores(); + accountStore.setActiveAccount(accountId); accountStore.updateAccount(accountId, { isConnected: true, diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index 375b8fb9..2490cd8d 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -89,6 +89,11 @@ export const useCalendarStore = create()( }, fetchCalendars: async () => { + // CalendarScreen fires this on mount; on cold start that happens before + // restoreSession has connected jmapClient. Bail rather than surfacing + // a "Not authenticated" error - the refetch driven by the auth-store + // will run this again once the session is live. + if (!jmapClient.isConnected) return; try { const calendars = (await fetchCalendars()) ?? []; set({ calendars }); @@ -98,6 +103,7 @@ export const useCalendarStore = create()( }, fetchEvents: async (calendarIds, after, before) => { + if (!jmapClient.isConnected) return; set({ loading: true, error: null }); try { const ids = (await queryEvents(calendarIds, after, before)) ?? []; @@ -142,6 +148,7 @@ export const useCalendarStore = create()( }, handleStateChange: async (change) => { + if (!jmapClient.isConnected) return; const accountId = jmapClient.accountId; const accountChanges = change.changed?.[accountId]; if (!accountChanges) return; diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 16819857..47f72438 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -87,6 +87,10 @@ export const useContactsStore = create()( }, fetchAddressBooks: async () => { + // No-op until a JMAP session exists. ContactsScreen fires this from a + // mount-time useEffect, which on cold start runs before restoreSession + // has set up the client. + if (!jmapClient.isConnected) return; try { const addressBooks = (await fetchAddressBooks()) ?? []; set({ addressBooks }); @@ -96,6 +100,7 @@ export const useContactsStore = create()( }, fetchContacts: async (filter) => { + if (!jmapClient.isConnected) return; set({ loading: true, error: null }); try { const ids = (await queryContacts(filter)) ?? []; @@ -111,6 +116,7 @@ export const useContactsStore = create()( }, handleStateChange: async (change) => { + if (!jmapClient.isConnected) return; const accountId = jmapClient.accountId; const accountChanges = change.changed?.[accountId]; if (!accountChanges) return; diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 4a81b296..a7b25ea4 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -129,6 +129,13 @@ export const useEmailStore = create()( pendingUndo: null, fetchMailboxes: async () => { + // Skip silently when there's no live session. Screens fire this from + // mount-time useEffects, and on cold start App.tsx renders MainTabs + // before restoreSession() finishes; without this guard the underlying + // API call would throw "Not authenticated - call connect() first" and + // surface as a user-visible error before the real fetch (kicked off by + // refetchFeatureStores() once the session is live) replaces it. + if (!jmapClient.isConnected) return; try { const mailboxes = await fetchMailboxes(); set({ mailboxes }); @@ -171,6 +178,15 @@ export const useEmailStore = create()( } } + // Stop here if there's no live session yet — the cached seed already + // gave the user something to look at, and the refetch driven by + // restoreSession() / switchAccount will run the network half once the + // client is connected. + if (!jmapClient.isConnected) { + set({ loading: false }); + return; + } + try { const filter = buildJmapFilter(mailboxId, '', {}); const { ids, total } = await queryEmails(mailboxId, { limit, filter }); @@ -195,6 +211,7 @@ export const useEmailStore = create()( loadMoreEmails: async () => { const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get(); if (!currentMailboxId || loading || emails.length >= totalEmails) return; + if (!jmapClient.isConnected) return; set({ loading: true }); try { @@ -215,6 +232,7 @@ export const useEmailStore = create()( refreshEmails: async () => { const { currentMailboxId, searchQuery, filters, emails: existing } = get(); if (!currentMailboxId) return; + if (!jmapClient.isConnected) return; // Keep existing emails visible while refreshing - swap atomically once // the new list is ready so pull-to-refresh doesn't flash an empty list. set({ loading: true, error: null });