From b6581a239d37e4f7a423026d346e573e4a83a2cc Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Tue, 4 Aug 2026 15:16:55 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20keep=20autosave=20out?= =?UTF-8?q?=20of=20the=20send=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30s autosave tick could fire between the submit's awaits (draft save, editor export) and the send mutation, dispatching a draft PUT concurrently with POST /send/ — the client half of the recipient-rewrite race fixed backend-side. Stop the timer before any await, wait for a blur-triggered save to settle right before sending, and restore the timer when the submit aborts since the draft stays open. --- .../forms/components/message-form/index.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index 72738e7e..c6588029 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -128,6 +128,12 @@ export const MessageForm = forwardRef(({ return localStorage.getItem(PREFER_SEND_MODE_KEY) as PreferSendMode ?? PreferSendMode.SEND; }); const saveDraftPromiseRef = useRef | null>(null); + // Blocks any non-forced draft save while a send is in flight: a draft PUT + // racing the send rewrites the recipients of a message already being + // delivered server-side. A ref (not state) so the guard is visible + // synchronously from the autosave timer and saveDraftInner, and survives + // the re-arms done by saveDraftInner's finally and the [draft] effect. + const isSendingRef = useRef(false); const [isSubmitting, setIsSubmitting] = useState(false); const [currentTime, setCurrentTime] = useState(new Date()); const autoSaveTimerRef = useRef(null); @@ -470,6 +476,8 @@ export const MessageForm = forwardRef(({ * Auto-save draft every 30 seconds */ const startAutoSave = () => { + // No autosave while a send is in flight (see isSendingRef). + if (isSendingRef.current) return; // Clear existing timer if (autoSaveTimerRef.current) { clearInterval(autoSaveTimerRef.current); @@ -522,6 +530,7 @@ export const MessageForm = forwardRef(({ * Returns the draft id on success. */ const saveDraftInner = async (force = false): Promise => { + if (isSendingRef.current && !force) return draft?.id; if (saveDraftPromiseRef.current) return saveDraftPromiseRef.current; const data = form.getValues(); @@ -626,6 +635,17 @@ export const MessageForm = forwardRef(({ if (!canSendMessages || !composerRef.current) return; setIsSubmitting(true); + // Block autosave for the whole send: the ref also guards + // startAutoSave against the re-arms performed by saveDraftInner's + // finally and the [draft] effect during the awaits below. + isSendingRef.current = true; + stopAutoSave(); + + const abortSend = () => { + isSendingRef.current = false; + setIsSubmitting(false); + if (draftRef.current) startAutoSave(); + }; try { // Wait for any in-progress draft save to complete @@ -634,7 +654,7 @@ export const MessageForm = forwardRef(({ // Ensure a draft exists before sending (creates one on-the-fly if needed) const messageId = draft?.id ?? await ensureDraft(); if (!messageId) { - setIsSubmitting(false); + abortSend(); return; } @@ -643,7 +663,9 @@ export const MessageForm = forwardRef(({ // DOM elements and triggers unwanted blob download requests). const { htmlBody, textBody } = await composerRef.current.exportContent(); - stopAutoSave(); + // A blur-triggered save may have started while we awaited above; + // let it settle so no draft PUT is in flight when the send fires. + await saveDraftPromiseRef.current; // Send (and "send and archive") moves the thread out of the drafts // filter — and possibly out of the inbox when archived. Drop the // pin upfront so the eventual refetch is authoritative. @@ -657,10 +679,17 @@ export const MessageForm = forwardRef(({ textBody: MailHelper.attachDriveAttachmentsToTextBody(textBody, data.driveAttachments), archive, } + }, { + onSuccess: () => { + isSendingRef.current = false; + }, + // The draft stays open when the send fails server-side; + // resume the autosave loop blocked for the send. + onError: abortSend, }); } catch (error) { console.warn("Error in handleSubmit:", error); - setIsSubmitting(false); + abortSend(); } };