fix(ical): prevent race condition in lazy loader

Use Promise-based singleton pattern to ensure concurrent calls share
the same loading promise instead of triggering multiple imports.

Also fix pre-existing test bug using await in sync function.
This commit is contained in:
Johannes Millan 2025-12-31 11:28:09 +01:00
parent 1cb1e1c742
commit 6d82c1982d
3 changed files with 57 additions and 16 deletions

View file

@ -828,16 +828,14 @@ END:VEVENT
END:VCALENDAR`;
// Should not throw an error
expect(() => {
const events = await getRelevantEventsForCalendarIntegrationFromIcal(
icalData,
calProviderId,
startTimestamp,
endTimestamp,
);
expect(events.length).toBe(1);
expect(events[0].title).toBe('Office 365 Meeting');
}).not.toThrow();
const events = await getRelevantEventsForCalendarIntegrationFromIcal(
icalData,
calProviderId,
startTimestamp,
endTimestamp,
);
expect(events.length).toBe(1);
expect(events[0].title).toBe('Office 365 Meeting');
});
it('should handle iCal with TZID reference to unknown timezone gracefully', async () => {

View file

@ -0,0 +1,35 @@
import { loadIcalModule } from './ical-lazy-loader';
describe('ical-lazy-loader', () => {
describe('loadIcalModule', () => {
it('should load the ical.js module', async () => {
const ICAL = await loadIcalModule();
expect(ICAL).toBeDefined();
expect(ICAL.parse).toBeDefined();
expect(ICAL.Component).toBeDefined();
});
it('should return the same module instance on subsequent calls', async () => {
const first = await loadIcalModule();
const second = await loadIcalModule();
expect(first).toBe(second);
});
it('should handle concurrent calls without race conditions', async () => {
const results = await Promise.all([
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
loadIcalModule(),
]);
const firstResult = results[0];
results.forEach((result) => {
expect(result).toBe(firstResult);
});
});
});
});

View file

@ -5,18 +5,26 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let icalModule: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let loadingPromise: Promise<any> | null = null;
/**
* Lazily loads the ical.js module on first use.
* Subsequent calls return the cached module.
* Concurrent calls share the same loading promise to prevent race conditions.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const loadIcalModule = async (): Promise<any> => {
if (!icalModule) {
// @ts-ignore - ical.js exports default
const mod = await import('ical.js');
// Handle both ESM default export and CommonJS module.exports
icalModule = mod.default || mod;
if (icalModule) {
return icalModule;
}
return icalModule;
if (!loadingPromise) {
loadingPromise = import('ical.js').then((mod) => {
// @ts-ignore - ical.js exports default
// Handle both ESM default export and CommonJS module.exports
icalModule = mod.default || mod;
return icalModule;
});
}
return loadingPromise;
};