From e7bf3c6e42661b845824f65cf81cca16f7f50564 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:51:17 +0200 Subject: [PATCH 01/29] test: add a benchmark-sized message corpus to the dev mock --- app/api/dev-jmap/[...path]/route.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index f4069ded6..c976f7fea 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -949,6 +949,73 @@ const emails: MockEmail[] = [ }, ]; +appendBenchCorpus(Number(process.env.DEV_MOCK_JMAP_BULK) || 0); + +/** + * DEV_MOCK_JMAP_BULK= replaces the fixtures above with n synthetic + * messages shaped like the benchmark rig's seed (repos/benchmark/seed): + * "#" subjects, 60 % plain text, the rest HTML or with an attachment, + * spread 70/15/5/5/5 over Inbox, Sent, Drafts, Junk and Trash. For + * performance tests, where a folder of a few messages hides every per-row + * cost. Deterministic, so counts are stable. + */ +function appendBenchCorpus(count: number): void { + if (count <= 0) return; + let seed = 0x1234abcd; + const rand = () => { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const pick = (list: T[]): T => list[Math.floor(rand() * list.length)]; + const prefixes = ['Re:', 'Fwd:', '', '', '', '[BENCH]', '[BENCH]', '[BENCH]']; + const nouns = ['Quarterly review', 'Project status', 'Lunch tomorrow?', 'Merge request', + 'Production incident', 'Vacation policy update', 'Architecture review', 'Customer feedback', + 'Standup notes', 'Performance budget', 'Release plan', 'Database migration', 'Security audit', + 'Team offsite', 'Onboarding doc', 'Roadmap Q4', 'Bug triage', 'Design review', 'Hiring loop', + 'Budget proposal', 'New customer', 'Postmortem draft']; + const senders = ['Alice', 'Bob', 'Carol', 'Dave', 'Marketing', 'Updates', 'Reports']; + const domains = ['github.com', 'example.com', 'stripe.com', 'vendor.io', 'product.com', 'analytics.io']; + const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. '; + const text = (chars: number) => lorem.repeat(Math.ceil(chars / lorem.length)).slice(0, chars); + const folders: Array<[string, number]> = [['mb-inbox', 0.70], ['mb-sent', 0.85], ['mb-drafts', 0.90], ['mb-junk', 0.95], ['mb-trash', 1]]; + const now = Date.now(); + emails.length = 0; + for (let n = 1; n <= count; n++) { + const name = pick(senders); + const body = `Hi,\n\n${text(800 + Math.floor(rand() * 2000))}\n\nSynthetic seed message ${n}/${count}.`; + const kind = rand(); + // The seed's attachment messages carry their own subject. + const subject = kind >= 0.95 ? `[BENCH] Document attached #${n}` : `${pick(prefixes)} ${pick(nouns)} #${n}`.trim(); + const slot = rand(); + const mailboxId = folders.find(([, upTo]) => slot < upTo)![0]; + const id = `email-bench-${n}`; + const email: MockEmail = { + id, threadId: `thread-bench-${n}`, mailboxIds: { [mailboxId]: true }, + keywords: { ...(rand() < 0.7 ? { $seen: true } : {}), ...(mailboxId === 'mb-drafts' ? { $draft: true } : {}) }, + size: body.length + 600, receivedAt: new Date(now - n * 37 * 60_000).toISOString(), + from: [{ name, email: `${name.toLowerCase()}@${pick(domains)}` }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], cc: [], + subject, preview: body.slice(4, 200).replace(/\s+/g, ' '), + hasAttachment: kind >= 0.95, + textBody: [{ partId: 'p1', blobId: `blob-${id}-p1`, size: body.length, type: 'text/plain' }], + htmlBody: [], + bodyValues: { p1: { value: body } }, + }; + if (kind >= 0.60 && kind < 0.95) { + const html = `

${subject}

${text(200)}

${Array.from({ length: 8 }, (_, i) => ``).join('')}
ItemValue
row-${i}${rand().toFixed(8)}

${text(kind < 0.80 ? 8000 : 40000)}

`; + email.htmlBody = [{ partId: 'p2', blobId: `blob-${id}-p2`, size: html.length, type: 'text/html' }]; + email.bodyValues.p2 = { value: html }; + } + if (email.hasAttachment) { + email.attachments = [{ partId: 'att1', blobId: `blob-${id}-att1`, size: 50_000, name: `synthetic-${n}.txt`, type: 'text/plain' }]; + } + emails.push(email); + } + recomputeMailboxCounts(); +} + // --------------------------------------------------------------------------- // Identities // --------------------------------------------------------------------------- From 2d67442a2bd81d1cbb8e4791b25f1c23eecc8e46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:51:17 +0200 Subject: [PATCH 02/29] perf: list search results without waiting for the highlighted excerpts --- lib/__tests__/jmap-client-extensions.test.ts | 51 ++++++++++++ lib/__tests__/search-snippet.test.ts | 34 ++++++++ .../unified-mailbox-search-accounts.test.ts | 22 ++++++ lib/jmap/client-interface.ts | 24 +++++- lib/jmap/client.ts | 46 +++++++++-- lib/search-snippet.ts | 37 +++++++++ lib/unified-mailbox.ts | 29 +++++-- .../email-store-plugin-search-hooks.test.ts | 2 +- .../email-store-search-accounts.test.ts | 18 ++--- .../email-store-search-scope.test.ts | 4 +- .../email-store-search-snippets.test.ts | 79 +++++++++++++++++++ stores/email-store.ts | 41 ++++++++-- 12 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 stores/__tests__/email-store-search-snippets.test.ts diff --git a/lib/__tests__/jmap-client-extensions.test.ts b/lib/__tests__/jmap-client-extensions.test.ts index 0543433b3..be6f818ce 100644 --- a/lib/__tests__/jmap-client-extensions.test.ts +++ b/lib/__tests__/jmap-client-extensions.test.ts @@ -200,6 +200,57 @@ describe('JMAP client extensions', () => { expect(result.emails).toHaveLength(1); expect(result.emails[0].searchSnippet).toBeUndefined(); }); + + describe('deferred', () => { + const hits = [ + { id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} }, + { id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} }, + ]; + const searchOrSnippets = (snippets: unknown) => (body: RequestBody) => body.methodCalls[0][0] === 'SearchSnippet/get' + ? { methodResponses: [snippets] } + : { methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { list: hits.map((hit) => ({ ...hit })) }, '1']] }; + + it('lists the results without waiting for the snippets', async () => { + await setup(); + answer(searchOrSnippets(['SearchSnippet/get', { list: [{ emailId: 'e1', subject: 'Invoice', preview: null }] }, 'snippets'])); + + const result = await client.searchEmails('invoice', 'inbox', undefined, 50, 0, { deferSnippets: true }); + + // The search itself no longer carries the snippet call ... + expect(requests[0].methodCalls.map((call) => call[0])).toEqual(['Email/query', 'Email/get']); + expect(result.emails.map((email) => email.id)).toEqual(['e1', 'e2']); + expect(result.emails[0].searchSnippet).toBeUndefined(); + // ... it follows on its own, for exactly the listed messages. + expect(await result.snippets).toEqual([{ emailId: 'e1', subject: 'Invoice', preview: null }]); + expect(requests[1].methodCalls).toEqual([['SearchSnippet/get', { + accountId: 'acct-1', + filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] }, + emailIds: ['e1', 'e2'], + }, 'snippets']]); + }); + + it('asks for no snippets when nothing could be highlighted', async () => { + await setup(); + answer(searchOrSnippets(['SearchSnippet/get', { list: [] }, 'snippets'])); + + const structural = await client.advancedSearchEmails({ hasKeyword: '$flagged' }, undefined, 50, 0, { deferSnippets: true }); + expect(structural.snippets).toBeUndefined(); + + answer(() => ({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] })); + const empty = await client.searchEmails('nothing', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(empty.snippets).toBeUndefined(); + expect(requests.every((body) => body.methodCalls[0][0] !== 'SearchSnippet/get')).toBe(true); + }); + + it('resolves to nothing instead of rejecting when the snippet request fails', async () => { + await setup(); + answer(searchOrSnippets(['error', { type: 'requestTooLarge' }, 'snippets'])); + + const result = await client.searchEmails('invoice', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(result.emails).toHaveLength(2); + await expect(result.snippets).resolves.toBeUndefined(); + }); + }); }); describe('deleteMailbox', () => { diff --git a/lib/__tests__/search-snippet.test.ts b/lib/__tests__/search-snippet.test.ts index 8772013de..a5974bf7e 100644 --- a/lib/__tests__/search-snippet.test.ts +++ b/lib/__tests__/search-snippet.test.ts @@ -5,6 +5,7 @@ import { hasMarkedSegment, parseSearchSnippet, snippetFilterFor, + withSearchSnippets, } from '../search-snippet'; import type { Email } from '@/lib/jmap/types'; @@ -96,3 +97,36 @@ describe('attachSearchSnippets', () => { expect(emails[0].searchSnippet).toBeUndefined(); }); }); + +describe('withSearchSnippets', () => { + const email = (id: string, accountId?: string) => ({ id, subject: `Subject ${id}`, accountId }) as unknown as Email; + const marked = (emailId: string) => ({ emailId, subject: `Subject ${emailId}`, preview: null }); + + it('returns new objects only for the emails that gained a snippet', () => { + const emails = [email('e1'), email('e2')]; + const next = withSearchSnippets(emails, [{ list: [marked('e2')] }]); + + expect(next).not.toBe(emails); + expect(next[0]).toBe(emails[0]); + expect(next[1]).not.toBe(emails[1]); + expect(next[1].searchSnippet).toEqual({ subject: 'Subject e2', preview: null }); + // The list already on screen is left untouched. + expect(emails[1].searchSnippet).toBeUndefined(); + }); + + it('returns the same list when nothing is highlighted', () => { + const emails = [email('e1')]; + expect(withSearchSnippets(emails, [])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [{ emailId: 'e1', subject: 'Subject e1', preview: null }] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [marked('other')] }])).toBe(emails); + }); + + it("keeps each account's snippets to its own rows, since ids repeat across accounts", () => { + const emails = [email('a', 'own'), email('a', 'group')]; + const next = withSearchSnippets(emails, [{ accountId: 'group', list: [marked('a')] }]); + + expect(next[0]).toBe(emails[0]); + expect(next[1].searchSnippet?.subject).toBe('Subject a'); + }); +}); diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts index 9008e8181..cda7d2ec1 100644 --- a/lib/__tests__/unified-mailbox-search-accounts.test.ts +++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts @@ -117,6 +117,28 @@ describe('searchAcrossAccounts', () => { }); }); +describe('deferred snippets across accounts', () => { + it("asks every account to defer and hands back each account's snippets under its id", async () => { + const ownSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-10T00:00:00Z', 'inbox')]), snippets: Promise.resolve([{ emailId: 'a', subject: 'own' }]) })); + const groupSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-12T00:00:00Z', 'group:g-inbox')]), snippets: Promise.resolve(undefined) })); + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, { searchEmails: ownSearch }); + const group = makeAccount({ accountId: 'group', isShared: true, mailboxes: groupMailboxes }, { searchEmails: groupSearch }); + + const result = await searchAcrossAccounts([own, group], 'x', 50, 0, { deferSnippets: true }); + + expect(ownSearch).toHaveBeenCalledWith('x', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(groupSearch).toHaveBeenCalledWith('x', undefined, 'group', 50, 0, { deferSnippets: true }); + // A failed or empty snippet request of one account drops out. + expect(await result.snippets).toEqual([{ accountId: 'login', list: [{ emailId: 'a', subject: 'own' }] }]); + }); + + it('carries no snippets when no account deferred any', async () => { + const own = makeAccount({ accountId: 'login' }, { searchEmails: vi.fn(async () => page([])) }); + const result = await searchAcrossAccounts([own], 'x', 50, 0); + expect(result.snippets).toBeUndefined(); + }); +}); + describe('advancedSearchAcrossAccounts', () => { it('sends the same folder-less filter to every account at the requested page', async () => { const filter = { operator: 'AND', conditions: [{ text: 'acesso*' }, { from: 'bob@example.com' }] }; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 670dd5838..f1883149a 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -2,6 +2,7 @@ import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountSta import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { FileNameRules } from "@/lib/file-name-rules"; import type { SortLevel } from "@/lib/message-list-order"; +import type { SearchSnippetResult } from "@/lib/search-snippet"; /** What `migrateKeyword` managed to do. */ export interface KeywordMigration { @@ -48,6 +49,24 @@ export interface CalendarEventUpdateOptions { keepSequence?: boolean; } +/** How a search request fetches its SearchSnippet/get highlights. */ +export interface SearchRequestOptions { + /** + * Fetch the snippets in a follow-up request instead of chaining them onto + * the search, and hand them back as `snippets`. For interactive searches, + * where the list must not wait for the highlighting. + */ + deferSnippets?: boolean; +} + +export interface SearchEmailsResult { + emails: Email[]; + hasMore: boolean; + total: number; + /** Set only with `deferSnippets`, when the filter has a term to highlight. */ + snippets?: Promise; +} + export interface IJMAPClient { // ── Connection lifecycle ────────────────────────────────────── connect(): Promise; @@ -224,13 +243,14 @@ export interface IJMAPClient { }): Promise<{ keywords: Record; scanned: number; total: number; complete: boolean }>; /** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */ getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record | null }>, accountId?: string): Promise>; - searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number, options?: SearchRequestOptions): Promise; advancedSearchEmails( filter: Record, accountId?: string, limit?: number, position?: number, - ): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + options?: SearchRequestOptions, + ): Promise; /** * Lean recipient search for compose autocomplete ("search the server" action): * finds messages in `sentMailboxId` whose to/cc matches `query` and returns diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index da90a1cf7..958945b72 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,7 +1,7 @@ import { generateUUID } from '@/lib/utils'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; +import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { batched, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; @@ -3290,7 +3290,7 @@ export class JMAPClient implements IJMAPClient { } } - async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise { try { const targetAccountId = accountId || this.accountId; @@ -3326,7 +3326,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], - this.searchSnippetCall(targetAccountId, filter), + ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]), ]); assertQuerySucceeded(response, 'Search'); @@ -3346,7 +3346,7 @@ export class JMAPClient implements IJMAPClient { namespaceMailboxIds(emails, accountId); } - return { emails, hasMore, total }; + return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) }; } catch (error) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); @@ -3372,6 +3372,35 @@ export class JMAPClient implements IJMAPClient { }, "snippets"]; } + /** + * With `deferSnippets`, the snippets of a result page as a request of their + * own, already in flight. Chained onto the search they cost no round trip + * but hold the whole response back while the server excerpts every hit, + * several times longer than the query itself, so an interactive search + * lists its results first and highlights them when this resolves. Never + * rejects: a failure leaves the results without snippets, as it does when + * chained. + */ + private deferredSnippets( + options: SearchRequestOptions, + accountId: string, + filter: Record | undefined, + emails: Email[], + ): Pick { + if (!options.deferSnippets || emails.length === 0 || !filter || !filterHasSnippetTerms(filter)) return {}; + const snippets = this.request([ + ["SearchSnippet/get", { + accountId, + filter: snippetFilterFor(filter), + emailIds: emails.map((email) => email.id), + }, "snippets"], + ]).then((response) => this.searchSnippetsFrom(response)).catch((error) => { + debug.warn("jmap", "SearchSnippet/get failed:", error); + return undefined; + }); + return { snippets }; + } + /** The SearchSnippet list of a search response, if the call succeeded. */ private searchSnippetsFrom(response: JMAPResponse): SearchSnippetResult[] | undefined { for (const [method, result, callId] of response.methodResponses ?? []) { @@ -3386,8 +3415,9 @@ export class JMAPClient implements IJMAPClient { filter: Record, accountId?: string, limit: number = 50, - position: number = 0 - ): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + position: number = 0, + options: SearchRequestOptions = {}, + ): Promise { try { const targetAccountId = accountId || this.accountId; @@ -3409,7 +3439,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], - this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined), + ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), ]); assertQuerySucceeded(response, 'Search'); @@ -3429,7 +3459,7 @@ export class JMAPClient implements IJMAPClient { namespaceMailboxIds(emails, accountId); } - return { emails, hasMore, total }; + return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) }; } catch (error) { console.error('Advanced search failed:', error); throw error; diff --git a/lib/search-snippet.ts b/lib/search-snippet.ts index 5fe84f8dd..e34584ece 100644 --- a/lib/search-snippet.ts +++ b/lib/search-snippet.ts @@ -109,6 +109,43 @@ export function snippetFilterFor(filter: Record): Record batch.list.length > 0) + .map((batch) => ({ accountId: batch.accountId, byId: new Map(batch.list.map((s) => [s.emailId, s])) })); + if (lookups.length === 0) return emails; + let changed = false; + const next = emails.map((email) => { + for (const { accountId, byId } of lookups) { + if (accountId !== undefined && email.accountId !== accountId) continue; + const snippet = byId.get(email.id); + if (!snippet) continue; + const [withSnippet] = attachSearchSnippets([{ ...email }], [snippet]); + if (!withSnippet.searchSnippet) return email; + changed = true; + return withSnippet; + } + return email; + }); + return changed ? next : emails; +} + /** * Attaches the snippets to the emails they belong to. A snippet without any * highlighted term (the server found nothing to mark, or returned the plain diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index e324610bb..8fac05d5c 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -1,6 +1,7 @@ import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types'; import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types'; -import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { IJMAPClient, SearchEmailsResult, SearchRequestOptions } from '@/lib/jmap/client-interface'; +import type { DeferredSnippetBatch } from '@/lib/search-snippet'; import { andFilters } from '@/lib/jmap/search-utils'; import { compareEmails, type SortLevel } from '@/lib/message-list-order'; @@ -43,6 +44,8 @@ export interface UnifiedFetchResult { total: number; hasMore: boolean; errors: Map; // accountId -> error message + /** Deferred search snippets per account (see SearchRequestOptions.deferSnippets). */ + snippets?: Promise; } export interface UnifiedMailboxCounts { @@ -591,7 +594,7 @@ export async function fetchTagEmails( ); } -export interface AcrossAccountsSearchOptions { +export interface AcrossAccountsSearchOptions extends SearchRequestOptions { /** * Leave every account's Trash and Junk out: the search panel's default * scope, "All folders except Spam and Trash". Its "All folders" scope @@ -624,8 +627,9 @@ export async function searchAcrossAccounts( return exclusion ? account.client.advancedSearchEmails( andFilters({ text: query.trim() }, exclusion), jmapAccountId, limit, positionFor(position, account), + ...searchRequestOptions(options), ) - : account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account)); + : account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account), ...searchRequestOptions(options)); }, newestFirst, ); @@ -649,11 +653,17 @@ export async function advancedSearchAcrossAccounts( jmapAccountId, limit, positionFor(position, account), + ...searchRequestOptions(options), ), newestFirst, ); } +/** The trailing SearchRequestOptions argument, left off when nothing is asked of it. */ +function searchRequestOptions(options: AcrossAccountsSearchOptions): [] | [SearchRequestOptions] { + return options.deferSnippets ? [{ deferSnippets: true }] : []; +} + function newestFirst(a: Email, b: Email): number { return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(); } @@ -669,14 +679,14 @@ async function fanOutAccountQuery( run: ( account: UnifiedAccountClient, jmapAccountId: string | undefined, - ) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>, + ) => Promise, sort: (a: Email, b: Email) => number, ): Promise { const errors = new Map(); type AccountResult = { account: UnifiedAccountClient; - result: { emails: Email[]; total: number; hasMore: boolean }; + result: SearchEmailsResult; } | null; const promises = accounts.map(async (account): Promise => { @@ -695,10 +705,14 @@ async function fanOutAccountQuery( let mergedEmails: Email[] = []; let totalSum = 0; let anyHasMore = false; + const snippetBatches: Promise[] = []; for (const outcome of results) { if (outcome.status !== 'fulfilled' || outcome.value === null) continue; const { account, result } = outcome.value; + if (result.snippets) { + snippetBatches.push(result.snippets.then((list) => (list ? { accountId: account.accountId, list } : null))); + } // Decorate shallow copies, not the shared client-returned objects. The // source stamps are what routes every later action (read, move, delete, // thread expansion) back to the owning account. @@ -717,7 +731,10 @@ async function fanOutAccountQuery( mergedEmails.sort(sort); - return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors }; + const snippets = snippetBatches.length > 0 + ? Promise.all(snippetBatches).then((batches) => batches.filter((batch): batch is DeferredSnippetBatch => batch !== null)) + : undefined; + return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors, ...(snippets ? { snippets } : {}) }; } /** diff --git a/stores/__tests__/email-store-plugin-search-hooks.test.ts b/stores/__tests__/email-store-plugin-search-hooks.test.ts index 25e45bc5c..30d37938f 100644 --- a/stores/__tests__/email-store-plugin-search-hooks.test.ts +++ b/stores/__tests__/email-store-plugin-search-hooks.test.ts @@ -47,7 +47,7 @@ describe('onSearch', () => { await useEmailStore.getState().searchEmails(client, 'invoice'); expect(handler).toHaveBeenCalledWith({ query: 'invoice', filters: DEFAULT_SEARCH_FILTERS }); - expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0, { deferSnippets: true }); expect(useEmailStore.getState().searchQuery).toBe('invoice'); }); diff --git a/stores/__tests__/email-store-search-accounts.test.ts b/stores/__tests__/email-store-search-accounts.test.ts index 73b2482f3..045be66f5 100644 --- a/stores/__tests__/email-store-search-accounts.test.ts +++ b/stores/__tests__/email-store-search-accounts.test.ts @@ -110,8 +110,8 @@ describe('"All folders" search across the own and group accounts (#1082)', () => expect(state.hasMoreEmails).toBe(false); // One folder-less query per account. expect(client.searchEmails).toHaveBeenCalledTimes(2); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true }); }); it('merges own and group hits newest first and stamps each with its source', async () => { @@ -151,7 +151,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () => await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0, { deferSnippets: true }); }); it('makes a single own-account query for a login without group folders', async () => { @@ -160,7 +160,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () => await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0, { deferSnippets: true }); expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'own-2']); }); @@ -281,8 +281,8 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.advancedSearchEmails).not.toHaveBeenCalled(); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true }); client.searchEmails.mockClear(); useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } }); @@ -296,7 +296,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true }); }); it('goes back to the default scope when the filters are cleared', () => { @@ -311,7 +311,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true }); useEmailStore.getState().selectMailbox(ownJunk.id); expect(useEmailStore.getState().searchMailboxId).toBe('junk'); @@ -326,7 +326,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0, { deferSnippets: true }); }); it('keeps a scope the user picked when entering or leaving Spam and Trash', () => { diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 2d114d18f..5d5462ee0 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -80,8 +80,8 @@ describe('search folder scope (#788)', () => { // No mailbox id -> the JMAP client omits the inMailbox constraint; the // shared folder's owner is asked as well (#1082). expect(client.searchEmails).toHaveBeenCalledTimes(2); - expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0, { deferSnippets: true }); }); it('advanced search from within a folder does not add an inMailbox condition', async () => { diff --git a/stores/__tests__/email-store-search-snippets.test.ts b/stores/__tests__/email-store-search-snippets.test.ts new file mode 100644 index 000000000..54e3001f6 --- /dev/null +++ b/stores/__tests__/email-store-search-snippets.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { SearchSnippetResult } from '@/lib/search-snippet'; + +// An interactive search lists its hits as soon as Email/query + Email/get +// answer; the SearchSnippet/get highlighting follows in a request of its own +// and is applied to the rows already on screen. + +const hit = (id: string): Email => ({ + id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`, + receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false, +} as Email); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +describe('interactive search with deferred snippets', () => { + let client: IJMAPClient; + let snippets: ReturnType>; + + beforeEach(() => { + snippets = deferred(); + client = { + searchEmails: vi.fn(async () => ({ emails: [hit('e1'), hit('e2')], hasMore: false, total: 2, snippets: snippets.promise })), + advancedSearchEmails: vi.fn(), + getSomeEmails: vi.fn().mockResolvedValue([]), + getAccountId: () => 'me', + } as unknown as IJMAPClient; + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + isUnifiedView: false, unifiedRole: null, crossView: null, viewingAccountId: null, selectedKeyword: null, + selectedMailbox: 'inbox', + // Scoped to one folder: a single searchEmails call on the client. + searchMailboxId: 'inbox', + searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchAbortController: null, emails: [], + mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 0 } as Mailbox], + accountMailboxes: {}, + }); + }); + + it('lists the results before the snippets arrive and highlights them after', async () => { + await useEmailStore.getState().searchEmails(client, 'subject'); + + expect(client.searchEmails).toHaveBeenCalledWith('subject', 'inbox', undefined, 50, 0, { deferSnippets: true }); + const listed = useEmailStore.getState().emails; + expect(listed.map((e) => e.id)).toEqual(['e1', 'e2']); + expect(listed[0].searchSnippet).toBeUndefined(); + + snippets.resolve([{ emailId: 'e2', subject: 'Subject e2', preview: null }]); + await vi.waitFor(() => expect(useEmailStore.getState().emails[1].searchSnippet).toBeDefined()); + const highlighted = useEmailStore.getState().emails; + expect(highlighted[1].searchSnippet).toEqual({ subject: 'Subject e2', preview: null }); + // The row without a snippet keeps its object, so it does not re-render. + expect(highlighted[0]).toBe(listed[0]); + }); + + it('drops the snippets when the search was left before they arrived', async () => { + await useEmailStore.getState().searchEmails(client, 'subject'); + useEmailStore.setState({ searchQuery: '' }); + + snippets.resolve([{ emailId: 'e1', subject: 'Subject e1', preview: null }]); + await snippets.promise; + await new Promise((r) => setTimeout(r, 0)); + + expect(useEmailStore.getState().emails.every((e) => e.searchSnippet === undefined)).toBe(true); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index ac536f195..4267eb075 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,7 +1,8 @@ import { create } from "zustand"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types"; import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types"; -import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface"; +import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet"; import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order"; @@ -1469,6 +1470,34 @@ function isUnscopedSearchActive( * The fan-out options of a folder-less search scope: only "All folders" * searches Trash and Junk, the default scope ("") leaves them out. */ +/** A single-account search result with its deferred snippets in fan-out shape. */ +function withSnippetBatches( + result: SearchEmailsResult, +): Omit & { snippets?: Promise } { + const { snippets, ...rest } = result; + return snippets ? { ...rest, snippets: snippets.then((list) => (list ? [{ list }] : [])) } : rest; +} + +/** + * Highlights the results of an interactive search once its deferred + * SearchSnippet/get lands (see SearchRequestOptions.deferSnippets). The list + * is on screen by then, so this applies only while that same search still is: + * matching ids would not do, since a folder opened in the meantime can hold + * the same messages and must not show search excerpts. + */ +function highlightWhenSnippetsArrive(snippets: Promise | undefined): void { + if (!snippets) return; + const { searchQuery, searchFilters, searchMailboxId } = useEmailStore.getState(); + const isSameView = captureEmailListView(); + void snippets.then((batches) => { + const state = useEmailStore.getState(); + if (!isSameView() || state.isLoading) return; + if (state.searchQuery !== searchQuery || state.searchFilters !== searchFilters || state.searchMailboxId !== searchMailboxId) return; + const emails = withSearchSnippets(state.emails, batches); + if (emails !== state.emails) useEmailStore.setState({ emails }); + }); +} + function acrossAccountsSearchOptions(searchMailboxId: string): AcrossAccountsSearchOptions { return { excludeTrashAndJunk: searchMailboxId !== SEARCH_SCOPE_ALL_FOLDERS }; } @@ -3679,7 +3708,7 @@ export const useEmailStore = create((set, get) => ({ // shared accounts' mail (#1082). The default scope leaves every // account's Trash and Junk out; "All folders" searches them too. const built = buildTagViewAccountClients(client); - result = await searchAcrossAccounts(built, query, emailsPerPage, 0, acrossAccountsSearchOptions(searchMailboxId)); + result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }); unifiedErrors = result.errors; } else { @@ -3691,7 +3720,7 @@ export const useEmailStore = create((set, get) => ({ // Only pass accountId for shared mailboxes, not for primary account accountId = mailbox?.isShared ? mailbox.accountId : undefined; - result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + result = withSnippetBatches(await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0, { deferSnippets: true })); } // Plugins may add messages to the results by id; the messages are @@ -3721,6 +3750,7 @@ export const useEmailStore = create((set, get) => ({ searchAbortController: null, ...(unifiedErrors ? { unifiedErrors } : {}) }); + highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined); } catch (error) { if (controller.signal.aborted) return; set({ @@ -3799,7 +3829,7 @@ export const useEmailStore = create((set, get) => ({ const built = buildTagViewAccountClients(client); result = await advancedSearchAcrossAccounts( built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, - acrossAccountsSearchOptions(searchMailboxId), + { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }, ); unifiedErrors = result.errors; @@ -3809,7 +3839,7 @@ export const useEmailStore = create((set, get) => ({ accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); + result = withSnippetBatches(await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0, { deferSnippets: true })); } if (controller.signal.aborted) return; @@ -3841,6 +3871,7 @@ export const useEmailStore = create((set, get) => ({ searchAbortController: null, ...(unifiedErrors ? { unifiedErrors } : {}) }); + highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined); } catch (error) { if (controller.signal.aborted) return; set({ From b59e3520f8e3efc7f3328218fe681b26dcd1df1d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:00:28 +0200 Subject: [PATCH 03/29] perf: stop re-rendering the message rows and mail view on unrelated store changes --- .../email/calendar-invitation-banner.tsx | 11 +- components/email/email-list.tsx | 37 ++++- components/email/email-viewer.tsx | 5 +- components/email/mv-list-toolbar.tsx | 21 ++- components/email/thread-conversation-view.tsx | 5 +- components/email/thread-email-item.tsx | 9 +- components/email/thread-list-item.tsx | 23 +++- components/layout/mobile-header.tsx | 3 +- components/layout/mv-top-bar.tsx | 3 +- components/layout/navigation-rail.tsx | 5 +- components/layout/sidebar.tsx | 11 +- components/mail/mail-app.tsx | 129 +++++++++++++++++- components/tour/tour-provider.tsx | 7 +- hooks/use-email-drag.ts | 3 +- hooks/use-mailbox-drop.ts | 12 +- hooks/use-media-query.ts | 3 +- hooks/use-tag-drop.ts | 5 +- 17 files changed, 258 insertions(+), 34 deletions(-) diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index dc9cc7a12..395beac22 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -22,6 +22,7 @@ import { useTranslations, useFormatter } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; +import { useShallow } from "zustand/react/shallow"; import { useEmailStore } from '@/stores/email-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -389,7 +390,15 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp const currentUserEmail = useAuthStore((s) => s.primaryIdentity?.email); const calendarInvitationParsingEnabled = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const timeFormat = useSettingsStore((s) => s.timeFormat); - const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(); + const { calendars, supportsCalendar, importEvents, rsvpEvent, updateEvent, events: storeEvents, setSelectedDate } = useCalendarStore(useShallow((state) => ({ + calendars: state.calendars, + supportsCalendar: state.supportsCalendar, + importEvents: state.importEvents, + rsvpEvent: state.rsvpEvent, + updateEvent: state.updateEvent, + events: state.events, + setSelectedDate: state.setSelectedDate, + }))); const [state, setState] = useState('loading'); const [parsedEvent, setParsedEvent] = useState | null>(null); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index cb6364fde..0ee95d697 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -14,6 +14,7 @@ import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } fr import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, type ListRow } from "@/stores/email-store"; +import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -116,7 +117,7 @@ export function EmailList({ const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); const tViewer = useTranslations('email_viewer'); - const { client } = useAuthStore(); + const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const { selectedEmailKeys, selectAllEmails: _selectAllEmails, @@ -149,7 +150,39 @@ export function EmailList({ searchQuery, isUnifiedView, unifiedRole, - } = useEmailStore(); + } = useEmailStore(useShallow((state) => ({ + selectedEmailKeys: state.selectedEmailKeys, + selectAllEmails: state.selectAllEmails, + clearSelection: state.clearSelection, + batchMarkAsRead: state.batchMarkAsRead, + batchSetTag: state.batchSetTag, + batchDelete: state.batchDelete, + batchMoveToMailbox: state.batchMoveToMailbox, + batchArchive: state.batchArchive, + batchMarkAsSpam: state.batchMarkAsSpam, + batchUndoSpam: state.batchUndoSpam, + loadMoreEmails: state.loadMoreEmails, + hasMoreEmails: state.hasMoreEmails, + isLoadingMore: state.isLoadingMore, + mailboxes: state.mailboxes, + selectedMailbox: state.selectedMailbox, + emptyMailbox: state.emptyMailbox, + expandedThreadIds: state.expandedThreadIds, + threadEmailsCache: state.threadEmailsCache, + isLoadingThread: state.isLoadingThread, + toggleThreadExpansion: state.toggleThreadExpansion, + fetchThreadEmails: state.fetchThreadEmails, + markThreadAsRead: state.markThreadAsRead, + collapseAllThreads: state.collapseAllThreads, + threadEmailCounts: state.threadEmailCounts, + searchFilters: state.searchFilters, + setSearchFilters: state.setSearchFilters, + clearSearchFilters: state.clearSearchFilters, + advancedSearch: state.advancedSearch, + searchQuery: state.searchQuery, + isUnifiedView: state.isUnifiedView, + unifiedRole: state.unifiedRole, + }))); // In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so // there is no concrete mailbox to read the role from. Fall back to the unified diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index df59d15dd..b5a866dd1 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -92,6 +92,7 @@ import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; +import { useShallow } from "zustand/react/shallow"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; @@ -1189,8 +1190,8 @@ export function EmailViewer({ // `sm:hidden` must not be trusted - the viewport may be desktop-sized // while the pane is phone-sized. const isPaneScoped = useIsPaneScoped(); - const { tabletListVisible } = useUIStore(); - const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); + const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible }))); + const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId }))); const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId)); // The standard mail UI supplies message routing for preview/download, // inline images, export and attachment drag-out. Other viewer hosts keep diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index d26ee1fc2..1a3117ff7 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -20,6 +20,7 @@ import { } from "@/components/icons"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; +import { useShallow } from "zustand/react/shallow"; import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store"; import { runBatchEmailAction } from "@/lib/email-action-toast"; import { toast } from "@/stores/toast-store"; @@ -129,7 +130,25 @@ export function MvListToolbar({ batchSetTag, isUnifiedView, unifiedRole, - } = useEmailStore(); + } = useEmailStore(useShallow((state) => ({ + emails: state.emails, + mailboxes: state.mailboxes, + selectedMailbox: state.selectedMailbox, + selectedEmailKeys: state.selectedEmailKeys, + // Read at render by resolveSelectedEmails (Move menu owner account). + threadEmailsCache: state.threadEmailsCache, + selectAllEmails: state.selectAllEmails, + clearSelection: state.clearSelection, + batchArchive: state.batchArchive, + batchDelete: state.batchDelete, + batchMarkAsRead: state.batchMarkAsRead, + batchMarkAsSpam: state.batchMarkAsSpam, + batchUndoSpam: state.batchUndoSpam, + batchMoveToMailbox: state.batchMoveToMailbox, + batchSetTag: state.batchSetTag, + isUnifiedView: state.isUnifiedView, + unifiedRole: state.unifiedRole, + }))); const [isProcessing, setIsProcessing] = useState(false); const [menuOpen, setMenuOpen] = useState(false); diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 3acf3b128..c60315b12 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -12,6 +12,7 @@ import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collaps import { fitEmailBodyWidth } from "@/lib/email-fit-width"; import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode"; import { useThemeStore } from "@/stores/theme-store"; +import { useShallow } from "zustand/react/shallow"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { formatDate, formatFileSize, cn, cleanPreview } from "@/lib/utils"; @@ -96,7 +97,7 @@ export function useConversationExternalContent() { const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const isTrustedAddressBookSender = useContactStore((state) => state.isTrustedAddressBookSender); const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook); - const { client } = useAuthStore(); + const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const [allowedIds, setAllowedIds] = useState>(new Set()); const allow = useCallback((emailId: string) => { @@ -513,7 +514,7 @@ export function ConversationEmailBody({ ); const [hasBlockedContent, setHasBlockedContent] = useState(false); const [cidBlobUrls, setCidBlobUrls] = useState>({}); - const { client } = useAuthStore(); + const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); // reader-above-quote: the body the quote was collapsed in, once it is on screen. const [htmlBodyEl, setHtmlBodyEl] = useState(null); const [plainBodyEl, setPlainBodyEl] = useState(null); diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index 83f54244d..c099fb225 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -10,6 +10,7 @@ import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "@/ import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; +import { useShallow } from "zustand/react/shallow"; import { useSettingsStore } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { emailKeyFor, getEmailTagIds } from "@/lib/thread-utils"; @@ -45,7 +46,13 @@ export function ThreadEmailItem({ // header pools them, so without this they disappear on the way in. const tagIds = sortTagIds(getEmailTagIds(email.keywords)); const sender = email.from?.[0]; - const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); + const { selectedMailbox, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(useShallow((state) => ({ + selectedMailbox: state.selectedMailbox, + selectedEmailKeys: state.selectedEmailKeys, + toggleEmailSelection: state.toggleEmailSelection, + selectRangeEmails: state.selectRangeEmails, + clearSelection: state.clearSelection, + }))); const density = useSettingsStore((state) => state.density); const isChecked = selectedEmailKeys.has(emailKeyFor(email)); diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 5ed9264fb..c9bb86224 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -13,6 +13,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons"; import type { AppIcon } from "@/components/icons"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useShallow } from "zustand/react/shallow"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { accountTintKey, generateAvatarColor } from "@/lib/account-utils"; import { useUIStore } from "@/stores/ui-store"; @@ -294,7 +295,16 @@ const SingleEmailItem = React.forwardRef( const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; const showPaperclip = useListHasAttachment(email, loadAttachments); - const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); + const { selectedMailbox, mailboxes, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(useShallow((state) => ({ + selectedMailbox: state.selectedMailbox, + mailboxes: state.mailboxes, + selectedEmailKeys: state.selectedEmailKeys, + toggleEmailSelection: state.toggleEmailSelection, + selectRangeEmails: state.selectRangeEmails, + clearSelection: state.clearSelection, + isUnifiedView: state.isUnifiedView, + unifiedRole: state.unifiedRole, + }))); // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). In aggregate role-views the selected mailbox is virtual → fall back // to the unified role so junk-contextual UI and avatar hiding work. @@ -849,7 +859,16 @@ export const ThreadListItem = React.forwardRef ({ + selectedMailbox: state.selectedMailbox, + mailboxes: state.mailboxes, + selectedEmailKeys: state.selectedEmailKeys, + toggleEmailSelection: state.toggleEmailSelection, + selectRangeEmails: state.selectRangeEmails, + clearSelection: state.clearSelection, + isUnifiedView: state.isUnifiedView, + unifiedRole: state.unifiedRole, + }))); const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined; diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx index 3dc6db4cd..654c47625 100644 --- a/components/layout/mobile-header.tsx +++ b/components/layout/mobile-header.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Menu, ArrowLeft, Plus, Search, X, MoreVertical, Terminal, Bell } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { useUIStore } from "@/stores/ui-store"; +import { useShallow } from "zustand/react/shallow"; import { useSettingsStore } from "@/stores/settings-store"; import { useIsDesktop } from "@/hooks/use-media-query"; import { cn } from "@/lib/utils"; @@ -130,7 +131,7 @@ export function MobileHeader({ allInboxesSelected = false, }: MobileHeaderProps) { const t = useTranslations('sidebar'); - const { toggleSidebar, goBack, sidebarOpen } = useUIStore(); + const { toggleSidebar, goBack, sidebarOpen } = useUIStore(useShallow((state) => ({ toggleSidebar: state.toggleSidebar, goBack: state.goBack, sidebarOpen: state.sidebarOpen }))); const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); // Pane-aware: in Pro split mode the viewport is desktop-wide while the // pane is narrow. The Tailwind `lg:hidden` variant alone would never fire diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx index 399ec8f2c..1785cb858 100644 --- a/components/layout/mv-top-bar.tsx +++ b/components/layout/mv-top-bar.tsx @@ -19,6 +19,7 @@ import type { AppIcon as AppIconType } from "@/components/icons"; import { useTranslations } from "next-intl"; import { Link, usePathname } from "@/i18n/navigation"; import { useAuthStore } from "@/stores/auth-store"; +import { useShallow } from "zustand/react/shallow"; import { useCalendarStore } from "@/stores/calendar-store"; import { useConfig } from "@/hooks/use-config"; import { usePolicyStore } from "@/stores/policy-store"; @@ -107,7 +108,7 @@ export function MvTopBar({ const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed); const client = useAuthStore((s) => s.client); - const { supportsCalendar } = useCalendarStore(); + const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar }))); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled")); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled")); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 8152d454d..e325f8264 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -7,6 +7,7 @@ import { AccountSwitcher } from "./account-switcher"; import { iconForName } from "@/components/icons"; import { useConfig } from "@/hooks/use-config"; import { useThemeStore } from "@/stores/theme-store"; +import { useShallow } from "zustand/react/shallow"; import { usePathname, Link, useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -208,8 +209,8 @@ export function NavigationRail({ const router = useRouter(); const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); - const { supportsCalendar } = useCalendarStore(); - const { mailboxes } = useEmailStore(); + const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar }))); + const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes }))); const client = useAuthStore((s) => s.client); const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a18d81780..9f7c279b2 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -57,6 +57,7 @@ import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; import { useAccountStore } from '@/stores/account-store'; +import { useShallow } from "zustand/react/shallow"; import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types'; import type { UnifiedMailboxRole } from '@/lib/jmap/types'; import { useDragDropContext } from "@/contexts/drag-drop-context"; @@ -746,7 +747,7 @@ function TagItem({ function DemoBanner() { const t = useTranslations('sidebar'); - const { isDemoMode, loginDemo } = useAuthStore(); + const { isDemoMode, loginDemo } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode, loginDemo: state.loginDemo }))); const { startTour, resetTourCompletion } = useTour(); const router = useRouter(); const [isResetting, setIsResetting] = useState(false); @@ -808,7 +809,7 @@ function DemoBanner() { function VacationBanner() { const t = useTranslations('sidebar'); const router = useRouter(); - const { isEnabled, isSupported } = useVacationStore(); + const { isEnabled, isSupported } = useVacationStore(useShallow((state) => ({ isEnabled: state.isEnabled, isSupported: state.isSupported }))); if (!isSupported || !isEnabled) return null; @@ -866,7 +867,7 @@ export function Sidebar({ mvShell = false, }: SidebarProps) { const router = useRouter(); - const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(); + const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed }))); // Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on // it slides the full list back out over the message list and takes it away // again on leaving, so a folder two clicks deep costs no clicks at all. It @@ -875,7 +876,7 @@ export function Sidebar({ const [railHovered, setRailHovered] = useState(false); const hoverExpanded = mvShell && sidebarCollapsed && railHovered; const isCollapsed = sidebarCollapsed && !hoverExpanded; - const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(); + const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId }))); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [expandedTags, setExpandedTags] = useState>(new Set()); const [showAllTags, setShowAllTags] = useState(false); @@ -951,7 +952,7 @@ export function Sidebar({ const showUnified = (multiAccountMode || enableUnifiedMailbox) && (crossAccountActive || (includeGroupInUnified && hasGroupInboxes) || anyCrossViewEnabled); - const { unifiedCounts } = useEmailStore(); + const { unifiedCounts } = useEmailStore(useShallow((state) => ({ unifiedCounts: state.unifiedCounts }))); // While the other logins are still reconnecting after a load, the unified // counts cover only the ones already back: say how many, so a partial total // is not read as the whole. Gone as soon as the restore finishes, whatever diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index f9ae1b1f9..ee7719b73 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -28,6 +28,7 @@ import { ThreadConversationView } from "@/components/email/thread-conversation-v import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, Mailbox, type StateChange, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store"; +import { useShallow } from "zustand/react/shallow"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; @@ -253,13 +254,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Whether the bootstrap fetch for the default mailbox has landed. Deep links // wait for it so their folder switch isn't overwritten (#733). const [initialMailLoadDone, setInitialMailLoadDone] = useState(false); - const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); - const { identities } = useIdentityStore(); + const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(useShallow((state) => ({ + isAuthenticated: state.isAuthenticated, + client: state.client, + logout: state.logout, + checkAuth: state.checkAuth, + switchAccount: state.switchAccount, + activeAccountId: state.activeAccountId, + isLoading: state.isLoading, + connectionLost: state.connectionLost, + isRateLimited: state.isRateLimited, + rateLimitUntil: state.rateLimitUntil, + }))); + const { identities } = useIdentityStore(useShallow((state) => ({ identities: state.identities }))); const multiAccountIdentities = useProMultiAccountIdentities(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); - const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); + const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(useShallow((state) => ({ loadTrustedSendersBook: state.loadTrustedSendersBook, trustedSendersLoaded: state.trustedSendersLoaded, loadRecentRecipients: state.loadRecentRecipients }))); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('email_viewer.reschedule_prompt')); @@ -342,7 +354,25 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // after a split closes and reopens at a different size. const clampEmailListWidth = (width: number) => paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width; - const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(); + const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({ + activeView: state.activeView, + sidebarOpen: state.sidebarOpen, + setSidebarOpen: state.setSidebarOpen, + setActiveView: state.setActiveView, + tabletListVisible: state.tabletListVisible, + setTabletListVisible: state.setTabletListVisible, + sidebarWidth: state.sidebarWidth, + emailListWidth: state.emailListWidth, + emailListHeight: state.emailListHeight, + setSidebarWidth: state.setSidebarWidth, + setEmailListWidth: state.setEmailListWidth, + setEmailListHeight: state.setEmailListHeight, + persistColumnWidths: state.persistColumnWidths, + sidebarCollapsed: state.sidebarCollapsed, + resetSidebarWidth: state.resetSidebarWidth, + resetEmailListWidth: state.resetEmailListWidth, + resetEmailListHeight: state.resetEmailListHeight, + }))); const { emails, mailboxes, @@ -432,7 +462,96 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { selectAccountMailbox, setViewingAccount, refreshCurrentMailbox, - } = useEmailStore(); + } = useEmailStore(useShallow((state) => ({ + emails: state.emails, + mailboxes: state.mailboxes, + selectedEmail: state.selectedEmail, + selectedMailbox: state.selectedMailbox, + quota: state.quota, + isPushConnected: state.isPushConnected, + newEmailNotification: state.newEmailNotification, + selectEmail: state.selectEmail, + selectMailbox: state.selectMailbox, + selectedEmailKeys: state.selectedEmailKeys, + selectAllEmails: state.selectAllEmails, + clearSelection: state.clearSelection, + toggleEmailSelection: state.toggleEmailSelection, + fetchMailboxes: state.fetchMailboxes, + fetchEmails: state.fetchEmails, + fetchQuota: state.fetchQuota, + sendEmail: state.sendEmail, + deleteEmail: state.deleteEmail, + markAsRead: state.markAsRead, + toggleStar: state.toggleStar, + patchEmailKeywords: state.patchEmailKeywords, + moveToMailbox: state.moveToMailbox, + moveToMailboxCrossAware: state.moveToMailboxCrossAware, + moveThreadToMailbox: state.moveThreadToMailbox, + searchEmails: state.searchEmails, + searchQuery: state.searchQuery, + setSearchQuery: state.setSearchQuery, + isLoading: state.isLoading, + isLoadingEmail: state.isLoadingEmail, + setLoadingEmail: state.setLoadingEmail, + setPushConnected: state.setPushConnected, + handleStateChange: state.handleStateChange, + clearNewEmailNotification: state.clearNewEmailNotification, + markAsSpam: state.markAsSpam, + undoSpam: state.undoSpam, + searchFilters: state.searchFilters, + searchMailboxId: state.searchMailboxId, + isAdvancedSearchOpen: state.isAdvancedSearchOpen, + setSearchFilters: state.setSearchFilters, + setSearchMailboxId: state.setSearchMailboxId, + scopeSearchToOpenFolder: state.scopeSearchToOpenFolder, + clearSearchFilters: state.clearSearchFilters, + toggleAdvancedSearch: state.toggleAdvancedSearch, + advancedSearch: state.advancedSearch, + selectedKeyword: state.selectedKeyword, + selectKeyword: state.selectKeyword, + hasMoreEmails: state.hasMoreEmails, + fetchTagCounts: state.fetchTagCounts, + fetchEmailContent: state.fetchEmailContent, + isUnifiedView: state.isUnifiedView, + unifiedRole: state.unifiedRole, + scheduledEmails: state.scheduledEmails, + scheduledTotal: state.scheduledTotal, + scheduledTotalByAccount: state.scheduledTotalByAccount, + scheduledHasMore: state.scheduledHasMore, + isLoadingScheduled: state.isLoadingScheduled, + isScheduledView: state.isScheduledView, + scheduledAccountScope: state.scheduledAccountScope, + setScheduledView: state.setScheduledView, + fetchScheduledEmails: state.fetchScheduledEmails, + loadMoreScheduledEmails: state.loadMoreScheduledEmails, + cancelScheduledEmailForEdit: state.cancelScheduledEmailForEdit, + rescheduleScheduledEmail: state.rescheduleScheduledEmail, + refreshScheduledMetadata: state.refreshScheduledMetadata, + cancelUndoSend: state.cancelUndoSend, + clearPendingUndoSend: state.clearPendingUndoSend, + pendingUndoSend: state.pendingUndoSend, + fetchUnifiedEmails: state.fetchUnifiedEmails, + fetchCrossView: state.fetchCrossView, + refreshUnifiedCounts: state.refreshUnifiedCounts, + refreshCrossCounts: state.refreshCrossCounts, + crossUnreadCount: state.crossUnreadCount, + exitUnifiedView: state.exitUnifiedView, + emptyMailbox: state.emptyMailbox, + markMailboxAsRead: state.markMailboxAsRead, + createMailbox: state.createMailbox, + renameMailbox: state.renameMailbox, + deleteMailbox: state.deleteMailbox, + batchDelete: state.batchDelete, + batchArchive: state.batchArchive, + batchMarkAsRead: state.batchMarkAsRead, + batchMarkAsSpam: state.batchMarkAsSpam, + batchUndoSpam: state.batchUndoSpam, + accountMailboxes: state.accountMailboxes, + viewingAccountId: state.viewingAccountId, + selectAccountMailbox: state.selectAccountMailbox, + setViewingAccount: state.setViewingAccount, + refreshCurrentMailbox: state.refreshCurrentMailbox, + }))); // Mailboxes of the account currently being browsed. Mirrors the store's // action-mailbox resolution so a lookup by `selectedMailbox` hits the same diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index 1f3382283..cce7e4653 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -3,6 +3,7 @@ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; import { useRouter, usePathname } from "@/i18n/navigation"; import { useAuthStore } from "@/stores/auth-store"; +import { useShallow } from "zustand/react/shallow"; import { useCalendarStore } from "@/stores/calendar-store"; import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; @@ -37,10 +38,10 @@ export function useTour() { export function TourProvider({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); - const { isDemoMode } = useAuthStore(); - const { supportsCalendar } = useCalendarStore(); + const { isDemoMode } = useAuthStore(useShallow((state) => ({ isDemoMode: state.isDemoMode }))); + const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar }))); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); - const { supportsWebDAV } = useWebDAVStore(); + const { supportsWebDAV } = useWebDAVStore(useShallow((state) => ({ supportsWebDAV: state.supportsWebDAV }))); const tourCompleted = useSettingsStore((s) => s.tourCompleted); const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices); const updateSetting = useSettingsStore((s) => s.updateSetting); diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 1a0c8cdee..e4e6db992 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react"; import { Email } from "@/lib/jmap/types"; import { IJMAPClient } from "@/lib/jmap/client-interface"; import { useEmailStore } from "@/stores/email-store"; +import { useShallow } from "zustand/react/shallow"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; @@ -142,7 +143,7 @@ function getReadyBundle(emails: Email[]): { url: string; name: string } | null { } export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn { - const { selectedEmailKeys, emails } = useEmailStore(); + const { selectedEmailKeys, emails } = useEmailStore(useShallow((state) => ({ selectedEmailKeys: state.selectedEmailKeys, emails: state.emails }))); const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext(); const isMobile = useUIStore((state) => state.isMobile); const client = useAuthStore((state) => state.client); diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index 5531da435..189719e1e 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -3,6 +3,7 @@ import { useCallback, useState, DragEvent } from "react"; import { Mailbox, Email } from "@/lib/jmap/types"; import { useEmailStore } from "@/stores/email-store"; +import { useShallow } from "zustand/react/shallow"; import { emailKeyFor } from "@/lib/thread-utils"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; @@ -66,8 +67,15 @@ interface UseMailboxDropReturn { export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn { const [isOver, setIsOver] = useState(false); - const { client } = useAuthStore(); - const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(); + const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); + const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailKeys, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore(useShallow((state) => ({ + moveEmailsToMailbox: state.moveEmailsToMailbox, + crossAccountMoveEmails: state.crossAccountMoveEmails, + selectedEmailKeys: state.selectedEmailKeys, + clearSelection: state.clearSelection, + refreshCurrentMailbox: state.refreshCurrentMailbox, + mailboxes: state.mailboxes, + }))); const { isDragging, sourceMailboxId, draggedEmails, endDrag, markDropped } = useDragDropContext(); // Determine if this is a valid drop target diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts index b61f356f1..67de1f276 100644 --- a/hooks/use-media-query.ts +++ b/hooks/use-media-query.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; import { useUIStore } from "@/stores/ui-store"; +import { useShallow } from "zustand/react/shallow"; import { usePaneSize } from "@/hooks/use-pane-size"; // Tailwind v4 breakpoints @@ -68,7 +69,7 @@ function classifyPane(paneWidth: number | null) { * it directly (mobile navigation helpers etc.). */ export function useDeviceDetection() { - const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(); + const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop }))); const paneWidth = usePaneSize(); const paneClassification = classifyPane(paneWidth); diff --git a/hooks/use-tag-drop.ts b/hooks/use-tag-drop.ts index 93629668a..58823d50c 100644 --- a/hooks/use-tag-drop.ts +++ b/hooks/use-tag-drop.ts @@ -2,6 +2,7 @@ import { useCallback, useState, DragEvent } from "react"; import { useEmailStore } from "@/stores/email-store"; +import { useShallow } from "zustand/react/shallow"; import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { dragDropHooks } from "@/lib/plugin-hooks"; @@ -26,8 +27,8 @@ interface UseTagDropReturn { export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): UseTagDropReturn { const [isOver, setIsOver] = useState(false); - const { client } = useAuthStore(); - const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(); + const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); + const { fetchEmails, fetchTagCounts, selectedMailbox } = useEmailStore(useShallow((state) => ({ fetchEmails: state.fetchEmails, fetchTagCounts: state.fetchTagCounts, selectedMailbox: state.selectedMailbox }))); const { isDragging, endDrag, markDropped } = useDragDropContext(); const handleDragOver = useCallback((e: DragEvent) => { 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 04/29] 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,
{ 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 && (