fix(notifications): cap pre-scheduled recurring reminders (#7850)

iOS caps pending local notifications at 64 globally across every reminder
effect and silently drops the overflow. Bound the recurring pre-scheduler
to the soonest-firing REPEAT_MAX_SCHEDULED occurrences (matching iOS's own
keep-soonest behaviour) and log any truncation rather than letting the OS
drop an arbitrary subset. Add a regression test covering the cap.
This commit is contained in:
Johannes Millan 2026-05-30 14:03:53 +02:00
parent 5e0be5f97f
commit e84e75d854
2 changed files with 49 additions and 0 deletions

View file

@ -745,5 +745,32 @@ describe('MobileNotificationEffects', () => {
generateNotificationId(tomorrowId),
);
}));
it('caps pre-scheduled reminders, keeping the soonest-firing (iOS 64-pending limit)', fakeAsync(() => {
// More configs than the cap (REPEAT_MAX_SCHEDULED = 32 in the effect),
// each due tomorrow at a distinct clock time 30 min apart (00:00 → 19:30).
const cfgs = Array.from({ length: 40 }, (_, i) => {
const h = String(Math.floor(i / 2)).padStart(2, '0');
const m = i % 2 === 0 ? '00' : '30';
return mkDailyCfg({ id: `cfg${i}`, startTime: `${h}:${m}` });
});
store.overrideSelector(selectActiveTaskRepeatCfgs, cfgs);
subscribeRepeatReminders();
tick(REPEAT_SETTLE_MS + 1);
// Bounded to the cap, not the full 40.
expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledTimes(32);
// Soonest kept, last-within-cap kept, first-over-cap dropped.
expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledWith(
jasmine.objectContaining({ triggerAtMs: triggerFor(1, '00:00') }),
);
expect(reminderServiceSpy.scheduleReminder).toHaveBeenCalledWith(
jasmine.objectContaining({ triggerAtMs: triggerFor(1, '15:30') }),
);
expect(reminderServiceSpy.scheduleReminder).not.toHaveBeenCalledWith(
jasmine.objectContaining({ triggerAtMs: triggerFor(1, '16:00') }),
);
}));
});
});

View file

@ -37,6 +37,16 @@ const DELAY_SCHEDULE = 5000;
// this window. See #7850.
const REPEAT_LOOKAHEAD_DAYS = 14;
// Upper bound on how many recurring reminders we pre-schedule in one pass.
// iOS caps pending local notifications at 64 GLOBALLY (across every reminder
// effect), silently dropping the rest, and it keeps the soonest-firing ones.
// We pre-schedule at most one occurrence per config, so this only bites a user
// with an unusually large number of timed recurring tasks — but when it does we
// want to deterministically keep the most imminent ones (matching iOS's own
// keep-soonest behaviour) and leave headroom for the live/due-date effects,
// rather than letting the OS choose for us. Truncation is logged, never silent.
const REPEAT_MAX_SCHEDULED = 32;
// Settle window for the recurring-reminder scheduler. Instance creation
// dispatches addTask → updateTaskRepeatCfg → scheduleTaskWithTime back-to-back,
// which the store surfaces as several intermediate emissions. Debouncing
@ -563,6 +573,18 @@ export class MobileNotificationEffects {
}
}
// Keep the soonest-firing occurrences when over the cap (see
// REPEAT_MAX_SCHEDULED). The day loop already yields roughly ascending
// order, but sort explicitly so the cap is deterministic.
result.sort((a, b) => a.triggerAtMs - b.triggerAtMs);
if (result.length > REPEAT_MAX_SCHEDULED) {
Log.log('MobileEffects: capping pre-scheduled repeat reminders', {
total: result.length,
cap: REPEAT_MAX_SCHEDULED,
});
return result.slice(0, REPEAT_MAX_SCHEDULED);
}
return result;
}