fix: load the calendar again after an account switch overtook its first load

This commit is contained in:
Linus Rath
2026-10-29 02:43:21 +01:00
parent 8a71495e1a
commit e993540ae6
2 changed files with 110 additions and 29 deletions
@@ -38,6 +38,7 @@ vi.mock('../../api/jmap-client', () => ({
})); }));
import * as calendarApi from '../../api/calendar'; import * as calendarApi from '../../api/calendar';
import { jmapClient } from '../../api/jmap-client';
import { JMAPMethodError } from '../../api/jmap-result'; import { JMAPMethodError } from '../../api/jmap-result';
import { import {
useCalendarStore, useCalendarStore,
@@ -382,6 +383,58 @@ describe('calendar-store', () => {
expect(mockGetCalendars).toHaveBeenCalled(); expect(mockGetCalendars).toHaveBeenCalled();
expect(mockQueryEvents).toHaveBeenCalledWith(['cal-1'], '2026-03-01', '2026-03-31', undefined); 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', () => { describe('handleStateChange', () => {
+57 -29
View File
@@ -299,8 +299,8 @@ export interface CalendarState {
reset: () => void; reset: () => void;
} }
// In-flight dedupe for the two whole-account loads (see fetchCalendars). // In-flight dedupe for the two whole-account loads (see loadCalendarList).
let calendarsInFlight: Promise<void> | null = null; let calendarsInFlight: Promise<boolean> | null = null;
let tasksInFlight: Promise<void> | null = null; let tasksInFlight: Promise<void> | null = null;
// One up with every reset (an account switch, a sign-out). A load still under // 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<boolean> {
// 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 { function persistHidden(ids: string[]): void {
void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch( void AsyncStorage.setItem(HIDDEN_CALENDARS_STORAGE_KEY, JSON.stringify(ids)).catch(
(err) => console.warn('[calendar-store] persist hidden failed', err), (err) => console.warn('[calendar-store] persist hidden failed', err),
@@ -737,29 +773,7 @@ export const useCalendarStore = create<CalendarState>()(
}, },
fetchCalendars: async () => { fetchCalendars: async () => {
// CalendarScreen fires this on mount; on cold start that happens before await loadCalendarList();
// 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;
}, },
fetchEvents: async (calendarIds, after, before) => { fetchEvents: async (calendarIds, after, before) => {
@@ -832,11 +846,19 @@ export const useCalendarStore = create<CalendarState>()(
const { loadedRange } = get(); const { loadedRange } = get();
if (loadedRange && rangeCovers(loadedRange, after, before)) return; 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 // Queries are windowed now, so load exactly the requested range instead
// of an ever-growing union (which re-downloaded everything each time). // of an ever-growing union (which re-downloaded everything each time).
if (get().calendars.length === 0) { if (get().calendars.length === 0) {
// Calendars haven't loaded yet - fetch them (deduped), then events. // 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); const calendarIds = get().calendars.map((c) => c.id);
if (calendarIds.length === 0) { if (calendarIds.length === 0) {
@@ -845,6 +867,7 @@ export const useCalendarStore = create<CalendarState>()(
} }
const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0; const needTasks = get().tasks.length === 0 && get().taskOnlyCalendarIds.length === 0;
await get().fetchEvents(calendarIds, after, before); await get().fetchEvents(calendarIds, after, before);
if (generation !== storeGeneration) return;
if (needTasks) void get().fetchTasks(); if (needTasks) void get().fetchTasks();
}, },
@@ -861,17 +884,22 @@ export const useCalendarStore = create<CalendarState>()(
await get().ensureRange(after, before); await get().ensureRange(after, before);
return; return;
} }
// Taken before the calendars load, as in ensureRange.
const generation = storeGeneration;
if (!jmapClient.isConnected) return; if (!jmapClient.isConnected) return;
// Calendars haven't loaded yet (cold start with a cached window): fetch // Calendars haven't loaded yet (cold start with a cached window): fetch
// them (deduped) first, like ensureRange. // 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 calendars = get().calendars;
const calendarIds = calendars.map((c) => c.id); const calendarIds = calendars.map((c) => c.id);
if (calendarIds.length === 0) { 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; return;
} }
const generation = storeGeneration;
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const incoming: CalendarEvent[] = []; const incoming: CalendarEvent[] = [];