diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index ee7719b73..31ffe05ad 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -121,6 +121,7 @@ import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbo import { mailtoComposerDraft } from "./mailto-draft"; import { getQuoteBodies } from "@/lib/email-composer-utils"; import { emailHooks } from "@/lib/plugin-hooks"; +import { afterRequestDispatch } from "@/lib/after-request-dispatch"; import { createMailboxWithHooks } from "@/lib/plugin-mail-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; @@ -3354,9 +3355,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const handleSearch = async (query: string) => { if (!client) return; - setSearchQuery(query); - useSearchHistoryStore.getState().addRecentSearch(query); + // Nothing the mail view renders from is written before the request has + // left (see afterRequestDispatch): searchEmails records the query itself, + // and the history entry only feeds the suggestion dropdown. + afterRequestDispatch(() => useSearchHistoryStore.getState().addRecentSearch(query)); if (!isFilterEmpty(searchFilters)) { + // advancedSearch reads the query from the store. + setSearchQuery(query); await advancedSearch(client); } else { await searchEmails(client, query); diff --git a/components/search/__tests__/search-box.test.tsx b/components/search/__tests__/search-box.test.tsx index e547c03dd..eecbeb989 100644 --- a/components/search/__tests__/search-box.test.tsx +++ b/components/search/__tests__/search-box.test.tsx @@ -202,3 +202,65 @@ describe('SearchBox and password managers', () => { expect(input()).toHaveAttribute('type', 'search'); }); }); + +describe('SearchBox draft', () => { + beforeEach(() => { + useSearchHistoryStore.setState({ recentSearches: [] }); + }); + + it('keeps the typed text to itself until it is submitted', () => { + const onChange = vi.fn(); + const onSubmit = vi.fn(); + render(); + fireEvent.change(input(), { target: { value: 'i' } }); + fireEvent.change(input(), { target: { value: 'in' } }); + fireEvent.change(input(), { target: { value: 'invoice' } }); + + expect(onChange).not.toHaveBeenCalled(); + expect(input().value).toBe('invoice'); + + fireEvent.submit(input().closest('form')!); + expect(onSubmit).toHaveBeenCalledWith('invoice'); + }); + + it('hands the typed text over when focus leaves the box', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(input(), { target: { value: 'invoice' } }); + fireEvent.blur(input()); + expect(onChange).toHaveBeenCalledWith('invoice'); + }); + + it('leaves the search on Enter in an emptied box, like the clear button', () => { + const onClear = vi.fn(); + const onSubmit = vi.fn(); + render(); + fireEvent.change(input(), { target: { value: '' } }); + fireEvent.submit(input().closest('form')!); + + expect(onClear).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('does nothing on Enter in an empty box when no search is in force', () => { + const onClear = vi.fn(); + render(); + fireEvent.submit(input().closest('form')!); + expect(onClear).not.toHaveBeenCalled(); + }); + + it('shows a query set from outside', () => { + function Outside() { + const [value, setValue] = React.useState(''); + return ( + <> + + {}} onClear={() => {}} onSelectContact={() => {}} /> + + ); + } + render(); + fireEvent.click(screen.getByText('set')); + expect(input().value).toBe('from:alice'); + }); +}); diff --git a/components/search/search-box.tsx b/components/search/search-box.tsx index ba223d24f..509e4f427 100644 --- a/components/search/search-box.tsx +++ b/components/search/search-box.tsx @@ -42,6 +42,18 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, const containerRef = useRef(null); const [open, setOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); + // The text being typed belongs to the box until it is submitted, cleared or + // the box loses focus. `value` is the search in force; handing it every + // keystroke re-rendered the whole mail view per character typed. + const [draft, setDraft] = useState(value); + const [adopted, setAdopted] = useState(value); + if (value !== adopted) { + setAdopted(value); + setDraft(value); + } + const commitDraft = () => { + if (draft !== value) onChange(draft); + }; const getAutocomplete = useContactStore((s) => s.getAutocomplete); // Subscribed only so the memo below recomputes as the lookup's inputs load. @@ -52,13 +64,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, const removeRecentSearch = useSearchHistoryStore((s) => s.removeRecentSearch); const suggestions = useMemo(() => { - const query = value.trim(); + const query = draft.trim(); const matches = query ? getAutocomplete(query).filter((r) => !r.group && r.email) : []; - return buildSearchSuggestions(value, recentSearches, matches); + return buildSearchSuggestions(draft, recentSearches, matches); // The three store slices are inputs of getAutocomplete (read via get()), // not of this closure — list them so the memo recomputes when they load. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]); + }, [draft, recentSearches, getAutocomplete, contacts, recentRecipients, directoryPrincipals]); const showDropdown = open && !disabled && suggestions.length > 0; const activeIndex = showDropdown && selectedIndex < suggestions.length ? selectedIndex : -1; @@ -72,6 +84,7 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, (suggestion: SearchSuggestion) => { close(); if (suggestion.kind === "recent") { + setDraft(suggestion.query); onChange(suggestion.query); onSubmit(suggestion.query); } else { @@ -112,9 +125,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact,
{ e.preventDefault(); - if (value.trim()) { + if (draft.trim()) { close(); - onSubmit(value); + onSubmit(draft); + } else if (value) { + // Enter on an emptied box leaves the search, as the clear button does. + close(); + onClear(); } }} className="relative" @@ -127,9 +144,9 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, type="search" autoFocus={autoFocus} placeholder={placeholder ?? t("search_placeholder_hint")} - value={value} + value={draft} onChange={(e) => { - onChange(e.target.value); + setDraft(e.target.value); setOpen(true); setSelectedIndex(-1); }} @@ -139,10 +156,13 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, // Focus moving into the dropdown (e.g. its action buttons) keeps it open. const next = e.relatedTarget as Node | null; if (next && containerRef.current?.contains(next)) return; + // Anything that reads the query from outside (the filter panel's + // search) sees what was typed once focus moves to it. + commitDraft(); close(); }} onKeyDown={handleKeyDown} - className={cn("ps-9 h-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", value && "pe-8")} + className={cn("ps-9 h-9 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none", draft && "pe-8")} role="combobox" aria-expanded={showDropdown} aria-controls={listId} @@ -154,10 +174,11 @@ export function SearchBox({ value, onChange, onSubmit, onClear, onSelectContact, disabled={disabled} title={title} /> - {value && ( + {draft && (