mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user