From e993540ae6b73750ce2e03226241ba11e330f1fe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:43:21 +0100 Subject: [PATCH] fix: load the calendar again after an account switch overtook its first load --- src/stores/__tests__/calendar-store.test.ts | 53 +++++++++++++ src/stores/calendar-store.ts | 86 ++++++++++++++------- 2 files changed, 110 insertions(+), 29 deletions(-) diff --git a/src/stores/__tests__/calendar-store.test.ts b/src/stores/__tests__/calendar-store.test.ts index be8465dc..4def2022 100644 --- a/src/stores/__tests__/calendar-store.test.ts +++ b/src/stores/__tests__/calendar-store.test.ts @@ -38,6 +38,7 @@ vi.mock('../../api/jmap-client', () => ({ })); import * as calendarApi from '../../api/calendar'; +import { jmapClient } from '../../api/jmap-client'; import { JMAPMethodError } from '../../api/jmap-result'; import { useCalendarStore, @@ -382,6 +383,58 @@ describe('calendar-store', () => { expect(mockGetCalendars).toHaveBeenCalled(); expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); }); + + it('marks an account without calendars as loaded', async () => { + mockGetCalendars.mockResolvedValue([]); + + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + + expect(useCalendarStore.getState().loadedRange).toEqual({ after: '2026-03-01', before: '2026-03-31' }); + }); + + it('leaves the range unloaded when the calendars fail to load, so the next call tries again', async () => { + mockGetCalendars.mockRejectedValueOnce(new Error('Network request failed')); + + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + expect(useCalendarStore.getState().loadedRange).toBeNull(); + + mockGetCalendars.mockResolvedValueOnce([{ id: 'cal-1', name: 'Personal' }]); + mockQueryEvents.mockResolvedValue([]); + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); + }); + + it('leaves the range unloaded without a session (an account loading)', async () => { + const client = jmapClient as { isConnected: boolean }; + client.isConnected = false; + try { + await useCalendarStore.getState().ensureRange('2026-03-01', '2026-03-31'); + await useCalendarStore.getState().extendRange('2026-03-01', '2026-03-31'); + } finally { + client.isConnected = true; + } + expect(useCalendarStore.getState().loadedRange).toBeNull(); + expect(mockGetCalendars).not.toHaveBeenCalled(); + }); + + it.each(['ensureRange', 'extendRange'] as const)('marks nothing loaded for the next account when a reset overtakes the calendar load (%s)', async (action) => { + let resolveCalendars: (v: unknown) => void = () => {}; + mockGetCalendars.mockImplementationOnce( + () => new Promise((resolve) => { resolveCalendars = resolve; }), + ); + if (action === 'extendRange') { + // A cold start: the window was cached, the calendars not yet loaded. + useCalendarStore.setState({ loadedRange: { after: '2026-02-01', before: '2026-03-01' } }); + } + const load = useCalendarStore.getState()[action]('2026-03-01', '2026-03-31'); + + useCalendarStore.getState().reset(); + resolveCalendars([]); + await load; + + expect(useCalendarStore.getState().loadedRange).toBeNull(); + expect(useCalendarStore.getState().calendars).toEqual([]); + }); }); describe('handleStateChange', () => { diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index fa000b60..f78c0755 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -299,8 +299,8 @@ export interface CalendarState { reset: () => void; } -// In-flight dedupe for the two whole-account loads (see fetchCalendars). -let calendarsInFlight: Promise | null = null; +// In-flight dedupe for the two whole-account loads (see loadCalendarList). +let calendarsInFlight: Promise | null = null; let tasksInFlight: Promise | null = null; // One up with every reset (an account switch, a sign-out). A load still under @@ -323,6 +323,42 @@ export class CalendarAccountChangedError extends Error { } } +/** + * fetchCalendars: load the calendar list into the store. True when it did; + * false without a session, when the request failed, or when a reset overtook + * it - the range loads then leave their range unloaded, so the next one + * tries again. + */ +function loadCalendarList(): Promise { + // 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 Promise.resolve(false); + // First-touch gate (#907): the screen mount, ensureRange and the + // auth-store all kick this off at once, and on a clustered Stalwart + // concurrent first Calendar/* requests can each lazily create a default + // calendar. Share one in-flight request instead. + if (calendarsInFlight) return calendarsInFlight; + const generation = storeGeneration; + const set = useCalendarStore.setState; + calendarsInFlight = (async () => { + try { + const calendars = sortCalendars((await fetchCalendars()) ?? []); + if (generation !== storeGeneration) return false; + set({ calendars }); + return true; + } catch (err) { + if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); + return false; + } finally { + // After a reset the slot belongs to the next account's load. + if (generation === storeGeneration) calendarsInFlight = null; + } + })(); + return calendarsInFlight; +} + function persistHidden(ids: string[]): void { void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( (err) => console.warn('[calendar-store] persist hidden failed', err), @@ -737,29 +773,7 @@ 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; - // First-touch gate (#907): the screen mount, ensureRange and the - // auth-store all kick this off at once, and on a clustered Stalwart - // concurrent first Calendar/* requests can each lazily create a default - // calendar. Share one in-flight request instead. - if (calendarsInFlight) return calendarsInFlight; - const generation = storeGeneration; - calendarsInFlight = (async () => { - try { - const calendars = sortCalendars((await fetchCalendars()) ?? []); - if (generation === storeGeneration) set({ calendars }); - } catch (err) { - if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' }); - } finally { - // After a reset the slot belongs to the next account's load. - if (generation === storeGeneration) calendarsInFlight = null; - } - })(); - return calendarsInFlight; + await loadCalendarList(); }, fetchEvents: async (calendarIds, after, before) => { @@ -832,11 +846,19 @@ export const useCalendarStore = create()( const { loadedRange } = get(); if (loadedRange && rangeCovers(loadedRange, after, before)) return; + // Taken before the calendars load: a reset meanwhile (an account switch) + // makes this range the previous account's. + const generation = storeGeneration; + if (!jmapClient.isConnected) return; + // Queries are windowed now, so load exactly the requested range instead // of an ever-growing union (which re-downloaded everything each time). if (get().calendars.length === 0) { // Calendars haven't loaded yet - fetch them (deduped), then events. - await get().fetchCalendars(); + // A list that didn't load (no session any more, a failed request) + // leaves the range unloaded: marked loaded, it stayed empty. + const listed = await loadCalendarList(); + if (generation !== storeGeneration || !listed) return; } const calendarIds = get().calendars.map((c) => c.id); if (calendarIds.length === 0) { @@ -845,6 +867,7 @@ export const useCalendarStore = create()( } const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0; await get().fetchEvents(calendarIds, after, before); + if (generation !== storeGeneration) return; if (needTasks) void get().fetchTasks(); }, @@ -861,17 +884,22 @@ export const useCalendarStore = create()( await get().ensureRange(after, before); return; } + // Taken before the calendars load, as in ensureRange. + const generation = storeGeneration; if (!jmapClient.isConnected) return; // Calendars haven't loaded yet (cold start with a cached window): fetch // them (deduped) first, like ensureRange. - if (get().calendars.length === 0) await get().fetchCalendars(); + if (get().calendars.length === 0) { + const listed = await loadCalendarList(); + if (generation !== storeGeneration || !listed) return; + } const calendars = get().calendars; const calendarIds = calendars.map((c) => c.id); if (calendarIds.length === 0) { - set({ loadedRange: plan.union }); + // Only the window this plan grew from: a jump elsewhere replaced it. + if (sameRange(get().loadedRange, loaded)) set({ loadedRange: plan.union }); return; } - const generation = storeGeneration; set({ loading: true, error: null }); try { const incoming: CalendarEvent[] = [];