fix: keep one account's tasks and events out of another's calendar after an account switch

This commit is contained in:
Linus Rath
2026-10-29 02:07:52 +01:00
parent 7c775571c9
commit 1f190c9402
2 changed files with 91 additions and 8 deletions
@@ -168,6 +168,53 @@ describe('calendar-store', () => {
});
});
describe('loads a reset overtook (an account switch)', () => {
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
it('keeps the previous account\'s tasks out of the next account\'s list', async () => {
const scan = deferred<unknown[]>();
mockScan.mockReturnValueOnce(scan.promise);
mockGetEvents.mockImplementation(async (ids: string[]) => ids.map((id) => ({ id, title: 'Old account task' })));
const outgoing = useCalendarStore.getState().fetchTasks();
useCalendarStore.getState().reset();
// The next account's own load is not handed the one still under way.
mockScan.mockResolvedValueOnce([]);
await useCalendarStore.getState().fetchTasks();
expect(mockScan).toHaveBeenCalledTimes(2);
scan.resolve([{ id: 't-old', '@type': 'Task', calendarIds: { 'cal-1': true } }]);
await outgoing;
expect(useCalendarStore.getState().tasks).toEqual([]);
});
it('keeps the previous account\'s events and calendars out', async () => {
const calendars = deferred<unknown[]>();
mockGetCalendars.mockReturnValueOnce(calendars.promise);
const ids = deferred<string[]>();
mockQueryEvents.mockReturnValueOnce(ids.promise);
mockGetEvents.mockResolvedValue([{ id: 'e-old', title: 'Old account event', start: '2026-03-20T15:00:00', calendarIds: { 'cal-1': true } }]);
useCalendarStore.setState({ calendars: [{ id: 'cal-1' }] as never });
const calendarLoad = useCalendarStore.getState().fetchCalendars();
const eventLoad = useCalendarStore.getState().fetchEvents(['cal-1'], '2026-03-01T00:00:00Z', '2026-04-01T00:00:00Z');
useCalendarStore.getState().reset();
calendars.resolve([{ id: 'cal-1', name: 'Old account calendar' }]);
ids.resolve(['e-old']);
await Promise.all([calendarLoad, eventLoad]);
const state = useCalendarStore.getState();
expect(state.calendars).toEqual([]);
expect(state.events).toEqual([]);
expect(state.loadedRange).toBeNull();
expect(state.error).toBeNull();
});
});
describe('fetchTasks', () => {
it('classifies calendars whose contents are all tasks as task lists (#28)', async () => {
// VTODO-only CalDAV collections (Todoist imports, Thunderbird task
+44 -8
View File
@@ -303,6 +303,21 @@ export interface CalendarState {
let calendarsInFlight: 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
// way from before keeps its answer to itself: landing after the reset, it put
// the previous account's calendars, events or tasks under the next account
// until the next refresh, and that account's own task load was handed the old
// one still in flight.
let storeGeneration = 0;
/** A range load the store's reset overtook: what it read belongs to the account before. */
export class CalendarAccountChangedError extends Error {
constructor() {
super('The calendar was reset while its events were loading');
this.name = 'CalendarAccountChangedError';
}
}
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),
@@ -328,7 +343,8 @@ function unionRange(loaded: LoadedRange, after: string, before: string): LoadedR
* (Stalwart >= 0.16.20, see lib/recurrence-instances), else on the device.
* Doesn't touch the store: fetchEvents keeps the result as the visible
* window, the reminders load their own upcoming window with it. Throws when
* the primary account fails; a failing shared account is skipped.
* the primary account fails, or when the store was reset meanwhile
* (CalendarAccountChangedError); a failing shared account is skipped.
*/
export async function loadEventsInRange(
calendars: Calendar[],
@@ -354,10 +370,13 @@ export async function loadEventsInRange(
}
if (groups.size === 0) groups.set(undefined, []);
const generation = storeGeneration;
const expand = !!after && !!before
&& await supportsSyntheticCalendarIds().catch(() => false);
const raw: CalendarEvent[] = [];
for (const [accountId, ids] of groups) {
// The client serves another account now: the rest would be read there.
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
let fetched: CalendarEvent[] | null = null;
@@ -391,9 +410,11 @@ export async function loadEventsInRange(
// A failing shared account must not hide the user's own events;
// remember an access rejection so it isn't re-probed every fetch.
if (!accountId) throw err;
noteCalendarAccessError(accountId, err);
// Not for the account the client serves since a reset.
if (generation === storeGeneration) noteCalendarAccessError(accountId, err);
}
}
if (generation !== storeGeneration) throw new CalendarAccountChangedError();
// Stalwart returns both Events and Tasks from CalendarEvent/query. Tasks
// (also CalDAV ones without an `@type`) are surfaced by fetchTasks so
// they don't pollute the grid.
@@ -721,14 +742,16 @@ export const useCalendarStore = create<CalendarState>()(
// 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()) ?? []);
set({ calendars });
if (generation === storeGeneration) set({ calendars });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
} finally {
calendarsInFlight = null;
// After a reset the slot belongs to the next account's load.
if (generation === storeGeneration) calendarsInFlight = null;
}
})();
return calendarsInFlight;
@@ -736,11 +759,14 @@ export const useCalendarStore = create<CalendarState>()(
fetchEvents: async (calendarIds, after, before) => {
if (!jmapClient.isConnected) return;
const generation = storeGeneration;
set({ loading: true, error: null });
try {
const events = await loadEventsInRange(get().calendars, calendarIds, after, before);
if (generation !== storeGeneration) return;
set({ events, loadedRange: { after, before }, loading: false });
} catch (err) {
if (generation !== storeGeneration) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
},
@@ -748,6 +774,7 @@ export const useCalendarStore = create<CalendarState>()(
fetchTasks: async () => {
if (!jmapClient.isConnected) return;
if (tasksInFlight) return tasksInFlight;
const generation = storeGeneration;
tasksInFlight = (async () => {
try {
const calendars = get().calendars;
@@ -756,6 +783,8 @@ export const useCalendarStore = create<CalendarState>()(
const tasks: CalendarEvent[] = [];
const taskOnly: string[] = [];
for (const accountId of accountIds) {
// The client serves another account now: the rest would be read there.
if (generation !== storeGeneration) return;
if (accountId && isCalendarAccessDenied(accountId)) continue;
try {
const scanned = await scanCalendarObjects(accountId);
@@ -780,14 +809,15 @@ export const useCalendarStore = create<CalendarState>()(
tasks.push(...fetched.map((e) => mapServerEventToStoreEvent(e, calendars, accountId)));
} catch (err) {
if (!accountId) throw err;
noteCalendarAccessError(accountId, err);
if (generation === storeGeneration) noteCalendarAccessError(accountId, err);
}
}
if (generation !== storeGeneration) return;
set({ tasks, taskOnlyCalendarIds: taskOnly });
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to load tasks' });
if (generation === storeGeneration) set({ error: err instanceof Error ? err.message : 'Failed to load tasks' });
} finally {
tasksInFlight = null;
if (generation === storeGeneration) tasksInFlight = null;
}
})();
return tasksInFlight;
@@ -836,12 +866,14 @@ export const useCalendarStore = create<CalendarState>()(
set({ loadedRange: plan.union });
return;
}
const generation = storeGeneration;
set({ loading: true, error: null });
try {
const incoming: CalendarEvent[] = [];
for (const piece of plan.pieces) {
incoming.push(...(await loadEventsInRange(calendars, calendarIds, piece.after, piece.before)));
}
if (generation !== storeGeneration) return;
// A jump elsewhere replaced the window meanwhile: these pieces no
// longer border it. (A refresh of the same window is fine.)
if (!sameRange(get().loadedRange, loaded)) {
@@ -854,6 +886,7 @@ export const useCalendarStore = create<CalendarState>()(
loading: false,
});
} catch (err) {
if (generation !== storeGeneration) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load events' });
}
},
@@ -1375,6 +1408,9 @@ export const useCalendarStore = create<CalendarState>()(
reset: () => {
// A new session may grant access the old one lacked, or reach an
// upgraded server.
storeGeneration += 1;
calendarsInFlight = null;
tasksInFlight = null;
resetCalendarAccessDenied();
resetSyntheticIdSupport();
resetTimeZoneChecks();