mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: load the calendar again after an account switch overtook its first load
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -299,8 +299,8 @@ export interface CalendarState {
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// In-flight dedupe for the two whole-account loads (see fetchCalendars).
|
||||
let calendarsInFlight: Promise<void> | null = null;
|
||||
// In-flight dedupe for the two whole-account loads (see loadCalendarList).
|
||||
let calendarsInFlight: Promise<boolean> | null = null;
|
||||
let tasksInFlight: Promise<void> | 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<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 {
|
||||
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<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;
|
||||
// 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<CalendarState>()(
|
||||
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<CalendarState>()(
|
||||
}
|
||||
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<CalendarState>()(
|
||||
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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user