From 8faf9002c2bf746ec3bdfd98415743b3b7b0b7ed Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 23 Aug 2026 14:14:49 -0700 Subject: [PATCH] Stop the composer stealing focus while the subject is typed The body editor was told to focus itself with autoFocus={d.to.length > 0 && Boolean(d.subject)} and RichEditor ran that as an effect keyed on the prop. Typing the first letter of a subject flipped Boolean(d.subject) false -> true, the effect fired, and the caret jumped from the subject line into the message body. autoFocus now means what it means on a DOM element: focus on mount. RichEditor captures the prop in a ref and focuses once, and the composer decides where the caret starts when it opens - recipients for a blank message, body for a reply that already has recipients and a subject - instead of deriving it from state that changes as the user types. initialFocusTarget is extracted and exported so the rule is stated in one place and tested. The regression test renders RichEditor and asserts it does not take focus from a field being typed into; it fails against the previous effect. --- web/src/views/compose/Composer.tsx | 20 +++++- web/src/views/compose/RichEditor.tsx | 29 +++++---- .../views/compose/__tests__/focus.test.tsx | 62 +++++++++++++++++++ 3 files changed, 95 insertions(+), 16 deletions(-) create mode 100644 web/src/views/compose/__tests__/focus.test.tsx diff --git a/web/src/views/compose/Composer.tsx b/web/src/views/compose/Composer.tsx index b992f5f..99be5e5 100644 --- a/web/src/views/compose/Composer.tsx +++ b/web/src/views/compose/Composer.tsx @@ -38,6 +38,11 @@ export function Composer({ draft }: { draft: Draft }) { const [showToolbar, setShowToolbar] = useState(true); const d = draft; const key = d.key; + // Where the caret starts, decided once when the composer opens: a blank + // message starts in the recipients, a reply (already addressed and titled) + // starts in the body. Deriving this from live state would move the caret + // while the user types. + const [initialFocus] = useState(() => initialFocusTarget(draft)); const patch = useCallback((p: Partial) => update(key, p), [update, key]); const onHtml = useCallback((html: string) => update(key, { html }), [update, key]); @@ -138,7 +143,7 @@ export function Composer({ draft }: { draft: Draft }) { )}
- patch({ to })} placeholder="Recipients" autoFocus={!d.to.length} /> + patch({ to })} placeholder="Recipients" autoFocus={initialFocus === "to"} /> {!d.showCc && } {!d.showBcc && } @@ -165,13 +170,13 @@ export function Composer({ draft }: { draft: Draft }) { )}
- patch({ subject: e.target.value })} autoFocus={d.to.length > 0 && !d.subject} /> + patch({ subject: e.target.value })} autoFocus={initialFocus === "subject"} /> {d.priority !== "normal" && {d.priority === "high" ? "High priority" : "Low priority"}} {d.requestReceipt && }
{d.format === "html" ? ( - addFiles(key, files)} showToolbar={showToolbar} autoFocus={d.to.length > 0 && Boolean(d.subject)} /> + addFiles(key, files)} showToolbar={showToolbar} autoFocus={initialFocus === "body"} /> ) : (