From 06d00e10eecfff77266a72e84647e66323108bc7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Thu, 15 Oct 2026 00:12:59 +0200
Subject: [PATCH] perf: keep the list on screen while a search runs and stop
re-rendering on every keystroke
---
components/mail/mail-app.tsx | 9 ++-
.../search/__tests__/search-box.test.tsx | 62 ++++++++++++++++
components/search/search-box.tsx | 41 ++++++++---
lib/after-request-dispatch.ts | 18 +++++
.../email-store-search-loading.test.ts | 73 +++++++++++++++++++
stores/email-store.ts | 33 +++++----
6 files changed, 208 insertions(+), 28 deletions(-)
create mode 100644 lib/after-request-dispatch.ts
create mode 100644 stores/__tests__/email-store-search-loading.test.ts
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,