fix(webmail): Undo send really undoes, and a send is not made twice

The countdown sent from inside a state updater and Undo's button submitted the
form, so Undo stopped the timer and then started the send over. The timer is
now held in a ref, Undo is type=button, and leaving the screen flushes a
pending send once. Snippets drop a stylesheet the preview was cut inside of.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 22:05:43 -03:00
1 parent 2080489319
commit 897f0122f4
5 files changed
+97 -12

No files matched your search

+49 -11
View File
@@ -87,6 +87,11 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
// way when that happens would otherwise land afterwards and bring the draft
// back, so every save checks this before and after it talks to the server.
const discardedRef = useRef(false);
// The send waiting out its ten seconds, so Undo can stop it and leaving the
// screen cannot lose it.
const pendingSend = useRef<{ timer: ReturnType<typeof setInterval>; html: string } | null>(null);
const sendRef = useRef<(html: string) => Promise<void>>(async () => undefined);
const unmounted = useRef(false);
const editorRef = useRef<HTMLDivElement>(null);
// The body, mirrored into React state.
@@ -98,6 +103,21 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
// that is why a draft left behind by a sent message was empty.
const [body, setBody] = useState("");
// Leaving with a send still counting down sends it now. Dropping it would lose
// a message the writer was told was on its way; Undo is the way to cancel.
useEffect(() => {
unmounted.current = false;
return () => {
unmounted.current = true;
const pending = pendingSend.current;
if (pending) {
clearInterval(pending.timer);
pendingSend.current = null;
void sendRef.current(pending.html);
}
};
}, []);
// An inline reply is opened to be written in, so the cursor starts in it.
useEffect(() => {
if (inline) editorRef.current?.focus();
@@ -290,16 +310,24 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
const bodyHtml = editorRef.current?.innerHTML || body;
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
void sendActualMessage(bodyHtml);
return null;
}
return prev - 1;
});
// The countdown is counted here and not inside a state updater. The send
// used to be a side effect of that updater, which React may run twice (the
// message went out twice) and which Undo could not stop: Undo only cleared
// the number, and the next tick - finding it empty - sent the message
// anyway.
let remaining = 10;
const timer = setInterval(() => {
remaining -= 1;
if (remaining > 0) {
setUndoSeconds(remaining);
return;
}
clearInterval(timer);
pendingSend.current = null;
setUndoSeconds(null);
void sendRef.current(bodyHtml);
}, 1000);
pendingSend.current = { timer, html: bodyHtml };
};
const sendActualMessage = async (htmlBody: string) => {
@@ -329,14 +357,22 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
setSending(false);
return;
}
onSent();
// A flush after leaving must not drag the writer back to Sent.
if (!unmounted.current) onSent();
} catch {
setSendError(t("errors.serverError"));
setSending(false);
}
};
sendRef.current = sendActualMessage;
const handleUndo = () => {
// Stops the timer itself: clearing the number on screen is not enough.
if (pendingSend.current) {
clearInterval(pendingSend.current.timer);
pendingSend.current = null;
}
setUndoSeconds(null);
setSending(false);
};
@@ -412,7 +448,9 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
remaining) (9s remaining)". */}
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
{/* type="button": inside this form a button submits it, so Undo used to
stop the countdown and then send the message again. */}
<Button type="button" variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="h-3.5 w-3.5" />
{t("mail.undoSendAction")}
</Button>