fix: block a second save while a plugin's form-save hook runs #1202

The hook ran before the modal entered its saving state, so Save and
Ctrl+Enter stayed live while a plugin handler was busy. Each extra press
created another event with a fresh uid and sent another invitation.

- mark the modal as saving before the hook runs
- pass uid and attendees to the conflict-check view too
- don't send a null meeting link when creating an event
This commit is contained in:
Linus Rath
2026-10-10 19:23:27 +02:00
parent 21c527018c
commit c46da33604
2 changed files with 101 additions and 36 deletions
@@ -0,0 +1,53 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { EventModal } from '../event-modal';
import { calendarFormHooks, removeAllPluginHooks } from '@/lib/plugin-hooks';
import type { Calendar } from '@/lib/jmap/types';
// The form-save hook runs before the event is saved. A plugin handler can take
// a while (minting a meeting room), and the save must count as in flight for
// that whole time, or a second click creates the new event twice.
const calendars = [
{ id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
] as unknown as Calendar[];
afterEach(() => {
removeAllPluginHooks('test-plugin');
});
describe('EventModal form-save hook', () => {
it('saves once when Save is pressed again while a plugin handler is running', async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
calendarFormHooks.onCalendarEventFormSave.register('test-plugin', async () => {
await gate;
return { virtualLocation: 'https://meet.example.com/room-1' };
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultCalendarId="cal-1"
onSave={onSave}
onClose={vi.fn()}
/>,
);
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Sync' } });
const save = screen.getByText('form.save');
fireEvent.click(save);
fireEvent.click(save);
fireEvent.keyDown(window, { key: 'Enter', ctrlKey: true });
await act(async () => { release(); });
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
// Give a stray second save the chance to show up.
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(onSave).toHaveBeenCalledTimes(1);
const data = onSave.mock.calls[0][0];
expect(Object.values(data.virtualLocations)[0]).toMatchObject({ uri: 'https://meet.example.com/room-1' });
});
});
+48 -36
View File
@@ -410,6 +410,8 @@ export function EventModal({
}, []);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Set synchronously, so a second save in the same tick is refused too.
const savingRef = useRef(false);
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
if (!event?.participants) return [];
@@ -464,12 +466,14 @@ export function EventModal({
location,
virtualLocation,
calendarId,
uid: event?.uid,
attendees: attendees.map((a) => a.email),
},
});
if (!cancelled) setPluginConflictWarnings(warnings);
}, 250);
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId]);
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]);
// Report live preview to parent for grid outline
useEffect(() => {
@@ -498,7 +502,7 @@ export function EventModal({
const handleSave = useCallback(async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || isSaving) return;
if (!trimmedTitle || isSaving || savingRef.current) return;
if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) {
// This guard returned silently, so a rejected save was indistinguishable from a dead
// button. The fields also enforce their limits via maxLength, so this is a backstop —
@@ -649,44 +653,52 @@ export function EventModal({
// (currently the virtual-location link) in the same save that sends
// scheduling messages, so invites carry what plugins set here rather
// than a stale value from before the save.
const pluginPatch = await calendarFormHooks.onCalendarEventFormSave.transform<
CalendarEventFormSavePatch
>(
{},
{
title: trimmedTitle,
description: trimmedDescription,
start: startStr,
end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
uid: data.uid ?? event?.uid,
attendees: effectiveAttendees.map((a) => a.email),
},
);
if (pluginPatch && typeof pluginPatch.virtualLocation === "string") {
if (pluginPatch.virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: pluginPatch.virtualLocation.trim(),
features: null,
},
};
} else {
data.virtualLocations = null;
}
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
// The handlers can take seconds (a plugin minting a meeting room), so the
// save counts as in flight from here: a second click or Ctrl+Enter must
// not start another save, which for a new event would create a second
// copy (fresh uid) and send its invitations twice.
savingRef.current = true;
setIsSaving(true);
try {
const pluginPatch = await calendarFormHooks.onCalendarEventFormSave.transform<
CalendarEventFormSavePatch
>(
{},
{
title: trimmedTitle,
description: trimmedDescription,
start: startStr,
end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
isAllDay: allDay,
location,
virtualLocation,
calendarId,
uid: data.uid ?? event?.uid,
attendees: effectiveAttendees.map((a) => a.email),
},
);
if (pluginPatch && typeof pluginPatch.virtualLocation === "string") {
if (pluginPatch.virtualLocation.trim()) {
data.virtualLocations = {
vl1: {
"@type": "VirtualLocation",
name: null,
description: null,
uri: pluginPatch.virtualLocation.trim(),
features: null,
},
};
} else if (event) {
data.virtualLocations = null;
} else {
delete data.virtualLocations;
}
}
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
await onSave(data, shouldSendScheduling);
} finally {
savingRef.current = false;
setIsSaving(false);
}
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]);