mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +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 * 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', () => {
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user