mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: resolve "Not authenticated" race on cold start and account switch
This commit is contained in:
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
isConnected: true,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -225,17 +225,42 @@ export const useAuthStore = create<AuthState>((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,
|
||||
|
||||
@@ -89,6 +89,11 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
},
|
||||
|
||||
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<CalendarState>()(
|
||||
},
|
||||
|
||||
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<CalendarState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
@@ -87,6 +87,10 @@ export const useContactsStore = create<ContactsState>()(
|
||||
},
|
||||
|
||||
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<ContactsState>()(
|
||||
},
|
||||
|
||||
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<ContactsState>()(
|
||||
},
|
||||
|
||||
handleStateChange: async (change) => {
|
||||
if (!jmapClient.isConnected) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
const accountChanges = change.changed?.[accountId];
|
||||
if (!accountChanges) return;
|
||||
|
||||
@@ -129,6 +129,13 @@ export const useEmailStore = create<EmailState>()(
|
||||
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<EmailState>()(
|
||||
}
|
||||
}
|
||||
|
||||
// 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<EmailState>()(
|
||||
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<EmailState>()(
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user