From c46da336049004e3d697c925b95509eaacc22915 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:23:27 +0200 Subject: [PATCH] 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 --- .../event-modal-form-save-hook.test.tsx | 53 ++++++++++++ components/calendar/event-modal.tsx | 84 +++++++++++-------- 2 files changed, 101 insertions(+), 36 deletions(-) create mode 100644 components/calendar/__tests__/event-modal-form-save-hook.test.tsx diff --git a/components/calendar/__tests__/event-modal-form-save-hook.test.tsx b/components/calendar/__tests__/event-modal-form-save-hook.test.tsx new file mode 100644 index 000000000..a92453de0 --- /dev/null +++ b/components/calendar/__tests__/event-modal-form-save-hook.test.tsx @@ -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((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( + , + ); + + 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' }); + }); +}); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 53b333280..a8d65cf64 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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]);