diff --git a/.github/workflows/perf-guard.yml b/.github/workflows/perf-guard.yml new file mode 100644 index 000000000..35ecb88ee --- /dev/null +++ b/.github/workflows/perf-guard.yml @@ -0,0 +1,44 @@ +name: "CI: Perf guard" + +# Render passes, component renders and JMAP requests per open / search / +# folder switch, against a production build with the dev mock's +# benchmark-sized corpus (scripts/perf-guard). Counts, not timings: they do +# not drift with runner load, so a failure means the code does more work. + +on: + push: + pull_request: + +jobs: + perf-guard: + name: Perf guard + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + # The same bundler the Docker image uses, with the dev mock compiled in. + - name: Build + env: + DEV_MOCK_JMAP: "true" + NEXT_TELEMETRY_DISABLED: "1" + GIT_COMMIT: ${{ github.sha }} + run: npx next build --webpack + + - name: Run perf guard + env: + CI: "true" + run: npm run test:perf-guard diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 19490bda6..a4ee53ec8 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -30,6 +30,7 @@ import { type LiteOAuthDiscovery, } from "@/lib/auth/lite-oauth"; import { toAsciiDomain } from "@/lib/idn"; +import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; /** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */ function completeAddressDomain(username: string): string { @@ -155,6 +156,7 @@ function LoginPageContent() { : ""; const mobileState = mobileRedirectUri ? rawMobileState : ""; const isMobileHandoff = Boolean(mobileRedirectUri); + usePrefetchMailView(!isMobileHandoff); const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig(); diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index f4069ded6..d6b957b61 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 // --------------------------------------------------------------------------- @@ -2414,6 +2481,24 @@ const METHOD_HANDLERS: Record = { // Resolve back-references between method calls // --------------------------------------------------------------------------- +/** A JSON pointer with "*" wildcard steps, as JMAP result references use. */ +function resolvePointer(value: unknown, path: string): unknown { + const steps = path.replace(/^\//, '').split('/').filter((step) => step.length > 0); + let current: unknown[] = [value]; + for (const step of steps) { + const next: unknown[] = []; + for (const item of current) { + if (step === '*') { + if (Array.isArray(item)) next.push(...item); + } else if (item && typeof item === 'object') { + next.push((item as Record)[step]); + } + } + current = next; + } + return steps.includes('*') ? current : current[0]; +} + function resolveBackReferences( methodCalls: Array<[string, MethodArgs, string]>, responses: MethodResult[], @@ -2422,15 +2507,17 @@ function resolveBackReferences( const [method, args, callId] = call; const resolved = { ...args }; - // Handle #ids back-reference (used by Email/get after Email/query) - if (resolved['#ids']) { - const ref = resolved['#ids'] as { resultOf: string; name: string; path: string }; + // Every "#name" argument is a back-reference (RFC 8620 §3.7): the value at + // `path` in an earlier response replaces it under the plain name, where a + // "*" step collects across an array (e.g. /list/*/threadId). + for (const key of Object.keys(resolved)) { + if (!key.startsWith('#')) continue; + const ref = resolved[key] as { resultOf: string; name: string; path: string }; const refResponse = responses.find((r) => r[2] === ref.resultOf && r[0] === ref.name); if (refResponse) { - const path = ref.path.replace(/^\//, ''); - resolved.ids = refResponse[1][path] as string[]; + resolved[key.slice(1)] = resolvePointer(refResponse[1], ref.path); } - delete resolved['#ids']; + delete resolved[key]; } return [method, resolved, callId] as [string, MethodArgs, string]; diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx index cba16b2d7..74e737138 100644 --- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -219,6 +219,8 @@ describe('message-row-actions', () => { const onSelect = vi.fn(); const [thread] = groupEmailsByThread([mail('a')]); render( {}} onEmailSelect={onSelect} />); + // The hover actions mount once the row has been pointed at. + fireEvent.pointerEnter(screen.getByTestId('email-list-item')); const button = screen.getByRole('button', { name: 'Later' }); const star = screen.getByTestId('hover-action-star'); @@ -234,12 +236,14 @@ describe('message-row-actions', () => { useSettingsStore.setState({ hoverActions: [] } as never); act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }])); render(row(mail('a'))); + fireEvent.pointerEnter(screen.getByTestId('email-list-item')); expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument(); }); it('honours the folder roles the plugin asked for', () => { act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }])); render(row(mail('a'))); + fireEvent.pointerEnter(screen.getByTestId('email-list-item')); expect(screen.queryByRole('button', { name: 'Later' })).toBeNull(); }); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index 47c79b1a6..90d21f827 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -617,3 +617,32 @@ describe('ThreadListItem Mountain View desktop row', () => { expect(onToggleExpand).not.toHaveBeenCalled(); }); }); + +describe('ThreadListItem prefetch on mouse press', () => { + const renderWithPrefetch = (onPrefetch: (email: Email) => void) => { + const [thread] = groupEmailsByThread([makeEmail()]); + return render( + {}} onEmailSelect={() => {}} onPrefetch={onPrefetch} />, + ); + }; + + it('asks for the message when a mouse is pressed on the row', () => { + const onPrefetch = vi.fn(); + renderWithPrefetch(onPrefetch); + fireEvent.pointerDown(screen.getByTestId('email-list-item'), { pointerType: 'mouse', button: 0 }); + expect(onPrefetch).toHaveBeenCalledWith(expect.objectContaining({ id: 'email-1' })); + }); + + it('leaves touches, other buttons and presses on controls in the row alone', () => { + const onPrefetch = vi.fn(); + renderWithPrefetch(onPrefetch); + const row = screen.getByTestId('email-list-item'); + fireEvent.pointerDown(row, { pointerType: 'touch', button: 0 }); + fireEvent.pointerDown(row, { pointerType: 'mouse', button: 2 }); + fireEvent.pointerEnter(row); + const control = row.querySelector('button'); + expect(control).not.toBeNull(); + fireEvent.pointerDown(control!, { pointerType: 'mouse', button: 0 }); + expect(onPrefetch).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/__tests__/thread-row-chips-mount.test.tsx b/components/email/__tests__/thread-row-chips-mount.test.tsx new file mode 100644 index 000000000..ac2be9be9 --- /dev/null +++ b/components/email/__tests__/thread-row-chips-mount.test.tsx @@ -0,0 +1,39 @@ +import { render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; + +// A row without an attachment mounts no attachment loader: there is nothing +// for it to load or show, and the list mounts dozens of rows at a time. + +const ListAttachmentChips = vi.fn(() => null); +vi.mock('../attachment-chips', async (importOriginal) => ({ + ...(await importOriginal()), + ListAttachmentChips, +})); +vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }) })); +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state) }; +}); + +const { ThreadListItem } = await import('../thread-list-item'); +const { groupEmailsByThread } = await import('@/lib/thread-utils'); + +const row = (id: string, hasAttachment: boolean) => ({ + id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 1, + receivedAt: '2026-01-15T10:00:00Z', subject: id, from: [{ name: 'Ann', email: 'ann@example.com' }], hasAttachment, +}) as unknown as Email; + +describe('attachment chips in message rows', () => { + it('mounts the loader only for rows that may have attachments', () => { + for (const [email, expected] of [[row('plain', false), 0], [row('with', true), 1]] as const) { + ListAttachmentChips.mockClear(); + const [thread] = groupEmailsByThread([email]); + const { unmount } = render( + {}} onEmailSelect={() => {}} onOpenAttachment={() => {}} />, + ); + expect(ListAttachmentChips.mock.calls.length > 0 ? 1 : 0).toBe(expected); + unmount(); + } + }); +}); diff --git a/components/email/__tests__/thread-row-memo.test.ts b/components/email/__tests__/thread-row-memo.test.ts new file mode 100644 index 000000000..901aaf13b --- /dev/null +++ b/components/email/__tests__/thread-row-memo.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { Email, ThreadGroup } from '@/lib/jmap/types'; +import { areThreadRowPropsEqual } from '../thread-list-item'; + +type RowProps = Parameters[0]; + +const email = (id: string, keywords: Record = {}): Email => + ({ id, threadId: 't1', subject: `Subject ${id}`, keywords, receivedAt: '2026-09-21T10:00:00Z' }) as unknown as Email; + +const group = (emails: Email[], over: Partial = {}): ThreadGroup => ({ + threadId: 't1', + threadKey: 'acct:t1', + emails, + latestEmail: emails[0], + participantNames: ['Ada'], + hasUnread: false, + hasStarred: false, + hasPinned: false, + hasAttachment: false, + hasAnswered: false, + hasForwarded: false, + emailCount: emails.length, + ...over, +}); + +const onToggleExpand = () => {}; +const onEmailSelect = () => {}; +const props = (over: Partial): RowProps => ({ + thread: group([email('e1')]), + isExpanded: false, + onToggleExpand, + onEmailSelect, + ...over, +}); + +describe('areThreadRowPropsEqual', () => { + it('treats a regrouped thread holding the very same messages as unchanged', () => { + const e1 = email('e1'); + // groupEmailsByThread builds new ThreadGroup objects on every pass. + expect(areThreadRowPropsEqual(props({ thread: group([e1]) }), props({ thread: group([e1]) }))).toBe(true); + }); + + it('re-renders when a message in the thread was replaced (read, starred, tagged ...)', () => { + const before = email('e1'); + const after = email('e1', { $seen: true }); + expect(areThreadRowPropsEqual(props({ thread: group([before]) }), props({ thread: group([after]) }))).toBe(false); + }); + + it('re-renders when the late thread count arrives', () => { + const e1 = email('e1'); + expect(areThreadRowPropsEqual( + props({ thread: group([e1], { emailCount: 1 }) }), + props({ thread: group([e1], { emailCount: 3 }) }), + )).toBe(false); + }); + + it('re-renders when a thread flag or the participants change', () => { + const e1 = email('e1'); + const base = props({ thread: group([e1]) }); + expect(areThreadRowPropsEqual(base, props({ thread: group([e1], { hasUnread: true }) }))).toBe(false); + expect(areThreadRowPropsEqual(base, props({ thread: group([e1], { participantNames: ['Ada', 'Grace'] }) }))).toBe(false); + }); + + it('ignores the open message moving between two OTHER rows', () => { + const thread = group([email('e1')]); + expect(areThreadRowPropsEqual( + props({ thread, selectedEmailId: 'other-1' }), + props({ thread, selectedEmailId: 'other-2' }), + )).toBe(true); + expect(areThreadRowPropsEqual(props({ thread }), props({ thread, selectedEmailId: 'other-1' }))).toBe(true); + }); + + it('re-renders the row that gains or loses the open message', () => { + const thread = group([email('e1'), email('e2')]); + expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'other' }), props({ thread, selectedEmailId: 'e2' }))).toBe(false); + expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'e2' }), props({ thread, selectedEmailId: 'other' }))).toBe(false); + expect(areThreadRowPropsEqual(props({ thread, selectedEmailId: 'e1' }), props({ thread, selectedEmailId: 'e2' }))).toBe(false); + }); + + it('counts messages shown by the expanded thread as drawn by the row', () => { + const thread = group([email('e1')]); + const expandedEmails = [email('e1'), email('e9')]; + expect(areThreadRowPropsEqual( + props({ thread, expandedEmails, isExpanded: true, selectedEmailId: 'other' }), + props({ thread, expandedEmails, isExpanded: true, selectedEmailId: 'e9' }), + )).toBe(false); + }); + + it('re-renders on any other prop change, including a handler appearing', () => { + const thread = group([email('e1')]); + expect(areThreadRowPropsEqual(props({ thread }), props({ thread, isExpanded: true }))).toBe(false); + expect(areThreadRowPropsEqual(props({ thread }), props({ thread, isLoading: true }))).toBe(false); + expect(areThreadRowPropsEqual(props({ thread }), props({ thread, onDelete: () => {} }))).toBe(false); + expect(areThreadRowPropsEqual(props({ thread }), props({ thread, onEmailSelect: () => {} }))).toBe(false); + }); +}); diff --git a/components/email/__tests__/thread-row-store-renders.test.tsx b/components/email/__tests__/thread-row-store-renders.test.tsx new file mode 100644 index 000000000..14bfcf07a --- /dev/null +++ b/components/email/__tests__/thread-row-store-renders.test.tsx @@ -0,0 +1,47 @@ +import { render, act, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// A message row follows the role of the folder it is listed in (sender vs +// recipient, junk actions), not the folder list itself: the list is a new +// array on every count update, and each one re-rendered every row. + +// Called once per render of a row: the render counter. +const useEmailDrag = vi.fn(() => ({ dragHandlers: {}, isDragging: false })); +vi.mock('@/hooks/use-email-drag', () => ({ useEmailDrag })); +vi.mock('@/stores/auth-store', () => { + const state = { identities: [], username: null }; + return { useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state) }; +}); + +const { ThreadListItem } = await import('../thread-list-item'); +const { useEmailStore } = await import('@/stores/email-store'); +const { groupEmailsByThread } = await import('@/lib/thread-utils'); + +const folder = (id: string, role: string | null, unreadEmails = 0) => + ({ id, name: id, role, unreadEmails, totalEmails: 1 }) as unknown as Mailbox; +const email = { + id: 'e1', threadId: 't1', mailboxIds: { sent: true }, keywords: { $seen: true }, size: 1, + receivedAt: '2026-01-15T10:00:00Z', subject: 'Hello', + from: [{ name: 'Me', email: 'me@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], +} as unknown as Email; + +describe('message row store subscription', () => { + it('ignores folder list updates that leave its folder role alone', () => { + useEmailStore.setState({ selectedMailbox: 'sent', isUnifiedView: false, unifiedRole: null, mailboxes: [folder('inbox', 'inbox'), folder('sent', 'sent')] }); + const [thread] = groupEmailsByThread([email]); + render( {}} onEmailSelect={() => {}} />); + // Sent shows the recipient. + expect(screen.getByText('Bob')).toBeInTheDocument(); + const mounted = useEmailDrag.mock.calls.length; + + // A count update: a new folder list, same roles. + act(() => { useEmailStore.setState({ mailboxes: [folder('inbox', 'inbox', 3), folder('sent', 'sent')] }); }); + expect(useEmailDrag.mock.calls.length).toBe(mounted); + + // The folder's role itself changing still shows. + act(() => { useEmailStore.setState({ mailboxes: [folder('inbox', 'inbox', 3), folder('sent', null)] }); }); + expect(useEmailDrag.mock.calls.length).toBeGreaterThan(mounted); + expect(screen.getByText('Me')).toBeInTheDocument(); + }); +}); diff --git a/components/email/__tests__/viewer-open-renders.test.tsx b/components/email/__tests__/viewer-open-renders.test.tsx new file mode 100644 index 000000000..1c4e865c3 --- /dev/null +++ b/components/email/__tests__/viewer-open-renders.test.tsx @@ -0,0 +1,71 @@ +import { render, act } from '@testing-library/react'; +import { Profiler } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; + +// Opening a message renders the reading pane twice: the row's header with the +// body loading, then the fetched body. Each further pass is a full render of +// a very large component on the click's critical path, so the count is pinned +// here: resets to values the pane already holds, and readiness flags reset in +// a layout effect, used to add passes on every open. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const row = (id: string) => ({ + id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: `Subject ${id}`, receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, +}) as unknown as Email; +const full = (id: string) => ({ + ...row(id), + textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: `

Body of ${id}

` } }, +}) as unknown as Email; + +beforeEach(() => clearAllHooks()); + +describe('reading pane renders per open', () => { + it('renders the pane a bounded number of times from click to body', async () => { + let commits = 0; + const pane = (email: Email, isLoading: boolean) => ( + { commits++; }}> + + + ); + const { rerender } = render(pane(full('a'), false)); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + + commits = 0; + // The click: the row with the body loading ... + rerender(pane(row('b'), true)); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + // ... then the fetched message. + rerender(pane(full('b'), false)); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + + // 13 before shared empty values and the derived iframe readiness. + expect(commits).toBeLessThanOrEqual(6); + }); +}); diff --git a/components/email/__tests__/viewer-toolbar-measure.test.tsx b/components/email/__tests__/viewer-toolbar-measure.test.tsx new file mode 100644 index 000000000..f5b2ec13d --- /dev/null +++ b/components/email/__tests__/viewer-toolbar-measure.test.tsx @@ -0,0 +1,92 @@ +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { clearAllHooks } from '@/lib/plugin-hooks'; + +// The toolbar moves what doesn't fit into its More menu, which takes reading +// every button's width: a forced layout. Opening another message usually +// leaves the toolbar as it was, and then the last answer is applied without +// reading any width. A resize still measures again. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +// Reports a size once observing starts, as browsers do, and on demand. +const observers = new Set<{ cb: ResizeObserverCallback; width: number }>(); +class ReportingResizeObserver { + private entry: { cb: ResizeObserverCallback; width: number }; + constructor(cb: ResizeObserverCallback) { this.entry = { cb, width: 800 }; } + observe() { + observers.add(this.entry); + const { cb } = this.entry; + queueMicrotask(() => cb([{ contentRect: { width: this.entry.width } } as ResizeObserverEntry], this as never)); + } + unobserve() {} + disconnect() { observers.delete(this.entry); } +} +vi.stubGlobal('ResizeObserver', ReportingResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; +const full = (id: string) => ({ + id, blobId: `b-${id}`, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: `Subject ${id}`, receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: `

Body of ${id}

` } }, +}) as unknown as Email; + +let widthReads = 0; +beforeEach(() => { + clearAllHooks(); + widthReads = 0; + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => { widthReads++; return 40; }); +}); +afterEach(() => vi.restoreAllMocks()); + +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 30)); }); +const pane = (email: Email, isLoading = false) => ( + +); + +describe('toolbar overflow measuring', () => { + it('measures once, not again for the next message with the same toolbar', async () => { + // An open shows the message loading, then loaded. + const { rerender } = render(pane(full('a'), true)); + await settle(); + rerender(pane(full('a'))); + await settle(); + expect(widthReads).toBeGreaterThan(0); + + widthReads = 0; + rerender(pane(full('b'), true)); + await settle(); + rerender(pane(full('b'))); + await settle(); + expect(widthReads).toBe(0); + }); + + it('measures again when the toolbar is resized', async () => { + const { rerender } = render(pane(full('a'))); + await settle(); + rerender(pane(full('b'))); + await settle(); + + widthReads = 0; + await act(async () => { + for (const o of observers) { o.width = 500; o.cb([{ contentRect: { width: 500 } } as ResizeObserverEntry], {} as ResizeObserver); } + await new Promise((r) => setTimeout(r, 30)); + }); + expect(widthReads).toBeGreaterThan(0); + }); +}); 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..e31e97500 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -1,7 +1,8 @@ "use client"; import { Email, ThreadGroup } from "@/lib/jmap/types"; -import { ThreadListItem } from "./thread-list-item"; +import { MemoizedThreadListItem } from "./thread-list-item"; +import { useStableHandlers } from "@/hooks/use-stable-handlers"; import type { Attachment } from "@/lib/jmap/types"; import type { LoadListAttachments } from "@/lib/list-attachments"; import { listRowShowsChips } from "./attachment-chips"; @@ -14,6 +15,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"; @@ -40,6 +42,8 @@ interface EmailListProps { emails: Email[]; selectedEmailId?: string; onEmailSelect?: (email: Email) => void; + /** A mouse was pressed on a row: the click that follows will open it. */ + onEmailPrefetch?: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; /** Mountain View: search for the mail a sender has sent, from the row menu. */ onFindFromSender?: (sender: { name: string; email: string }) => void; @@ -82,6 +86,7 @@ export function EmailList({ emails, selectedEmailId, onEmailSelect, + onEmailPrefetch, onEmailDoubleClick, onFindFromSender, className, @@ -116,7 +121,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 +154,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 @@ -558,6 +595,46 @@ export function EmailList({ for (const [key, emails] of members) emitThreadExpand(key, emails, false); }, [collapseAllThreads, threadMembers]); + // Rows are memoised (MemoizedThreadListItem), which only pays off if what + // they are handed keeps its identity from one render to the next. + const rowHandlers = useStableHandlers({ + onToggleExpandKey: handleToggleThreadExpansion, + onCollapseAllThreads: collapseAllThreadsAndNotify, + onEmailSelect: (email: Email) => { + // Collapse expanded threads when selecting an email outside the expanded thread + const currentExpanded = useEmailStore.getState().expandedThreadIds; + if (currentExpanded.size > 0) { + // Check if the selected email belongs to any expanded thread via its threadId + if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) { + collapseAllThreadsAndNotify(); + } + } + onEmailSelect?.(email); + }, + onEmailDoubleClick, + onPrefetch: onEmailPrefetch, + onContextMenu: openContextMenu, + onOpenConversation, + onToggleStar, + onMarkAsRead, + onDelete, + onArchive, + onSetTag, + onMarkAsSpam, + onUndoSpam, + onOpenAttachment, + }); + // One expand toggle per thread, kept for as long as the list lives. + const toggleExpandByThread = useRef(new Map void>()); + const toggleExpandFor = (threadKey: string) => { + let toggle = toggleExpandByThread.current.get(threadKey); + if (!toggle) { + toggle = () => rowHandlers.onToggleExpandKey(threadKey); + toggleExpandByThread.current.set(threadKey, toggle); + } + return toggle; + }; + // Range-based load more: trigger when last visible item is near the end. // Debounce to prevent rapid cascade when thread grouping reduces item // count below the viewport size (e.g. 2400 emails → fewer thread groups). @@ -587,9 +664,15 @@ export function EmailList({ thread.latestEmail.id === selectedEmailId || thread.emails.some(e => e.id === selectedEmailId) ); - if (index >= 0) { - virtualizer.scrollToIndex(index, { align: 'auto' }); - } + if (index < 0) return; + // A row clicked in the list is already on screen; scrollToIndex would + // still measure and schedule a scroll. Judge from the virtualizer's own + // numbers, which cost no layout read. + const item = virtualizer.measurementsCache[index]; + const top = virtualizer.scrollOffset; + const height = virtualizer.scrollRect?.height; + if (item && top !== null && height !== undefined && item.start >= top && item.end <= top + height) return; + virtualizer.scrollToIndex(index, { align: 'auto' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmailId]); @@ -811,36 +894,27 @@ export function EmailList({ width: '100%', }} > - handleToggleThreadExpansion(thread.threadKey)} - onCollapseAllThreads={collapseAllThreadsAndNotify} - onEmailSelect={(email) => { - // Collapse expanded threads when selecting an email outside the expanded thread - const currentExpanded = useEmailStore.getState().expandedThreadIds; - if (currentExpanded.size > 0) { - // Check if the selected email belongs to any expanded thread via its threadId - if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) { - collapseAllThreadsAndNotify(); - } - } - onEmailSelect?.(email); - }} - onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined} - onContextMenu={openContextMenu} - onOpenConversation={onOpenConversation} - onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} - onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} - onDelete={onDelete ? (email) => onDelete(email) : undefined} - onArchive={onArchive ? (email) => onArchive(email) : undefined} - onSetTag={onSetTag} - onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} - onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} - onOpenAttachment={onOpenAttachment} + onToggleExpand={toggleExpandFor(thread.threadKey)} + onCollapseAllThreads={rowHandlers.onCollapseAllThreads} + onEmailSelect={rowHandlers.onEmailSelect} + onEmailDoubleClick={rowHandlers.onEmailDoubleClick} + onPrefetch={rowHandlers.onPrefetch} + onContextMenu={rowHandlers.onContextMenu} + onOpenConversation={rowHandlers.onOpenConversation} + onToggleStar={rowHandlers.onToggleStar} + onMarkAsRead={rowHandlers.onMarkAsRead} + onDelete={rowHandlers.onDelete} + onArchive={rowHandlers.onArchive} + onSetTag={rowHandlers.onSetTag} + onMarkAsSpam={rowHandlers.onMarkAsSpam} + onUndoSpam={rowHandlers.onUndoSpam} + onOpenAttachment={rowHandlers.onOpenAttachment} loadAttachments={loadAttachments} /> diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index df59d15dd..2d018bf88 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -16,6 +16,7 @@ import { useCopyLink } from "@/hooks/use-copy-link"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils"; +import { itemsToHide } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { emailDisplayDate } from "@/lib/email-date"; @@ -92,6 +93,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"; @@ -981,6 +983,12 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component ); } +// Shared "nothing here" values for the per-message attachment state. Every +// open resets that state; a fresh [] each time is a new value, so React +// renders the viewer once more even though nothing changed. +const NO_POSTAL_ATTACHMENTS: PostalMimeAttachment[] = []; +const NO_TNEF_ATTACHMENTS: TnefAttachment[] = []; + export function EmailViewer({ email, blobSource, @@ -1189,8 +1197,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 @@ -1381,6 +1389,17 @@ export function EmailViewer({ return () => cancelAnimationFrame(frame); }, [moreMenuSub, moreMenuOpen, isMobile]); const [hiddenPriorities, setHiddenPriorities] = useState>(new Set()); + // What the overflow measurement last applied. It mostly runs from observer + // and animation-frame callbacks, where even an updater that returns the + // previous state makes React call this component once more before bailing + // out; comparing here means an unchanged result schedules nothing at all. + const appliedHiddenPrioritiesRef = useRef>(hiddenPriorities); + // The toolbar's overflow answers measured so far, by what the toolbar + // looked like (see layoutKey), and the width it last had: an open that + // brings back a toolbar already measured at this width needs no measuring. + // A loading message and a loaded one can differ, so a few are kept. + const measuredToolbarRef = useRef(new Map>()); + const toolbarWidthRef = useRef(undefined); const currentTagIds = getEmailTagIds(email?.keywords); const sortedTagIds = sortTagIds(currentTagIds); // The header spans the reading pane, so it measures its own width rather than @@ -1394,7 +1413,7 @@ export function EmailViewer({ // crypto plugin's own email-banner slot, so the host keeps no S/MIME state. const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); const [pluginRenderedText, setPluginRenderedText] = useState(null); - const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]); + const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState(NO_POSTAL_ATTACHMENTS); // Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin // after the user unlocks a key from the banner) to force the onRenderEmailBody // hook to run again for the open message so the body re-decrypts. @@ -1408,12 +1427,12 @@ export function EmailViewer({ // TNEF (winmail.dat) support const [tnefHtml, setTnefHtml] = useState(null); const [tnefText, setTnefText] = useState(null); - const [tnefAttachments, setTnefAttachments] = useState([]); + const [tnefAttachments, setTnefAttachments] = useState(NO_TNEF_ATTACHMENTS); // Embedded message/rfc822 unwrapping (Outlook forward-as-attachment) const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState(null); const [embeddedEmailText, setEmbeddedEmailText] = useState(null); - const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); + const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState(NO_POSTAL_ATTACHMENTS); // Plugin detail sidebar state. Collapsed/width persist across opens and // sessions so the panel reopens the way the user last left it. @@ -1540,11 +1559,34 @@ export function EmailViewer({ let rafId: number | null = null; + const applyHidden = (hidden: Set) => { + const applied = appliedHiddenPrioritiesRef.current; + if (applied.size === hidden.size && [...hidden].every(p => applied.has(p))) return; + appliedHiddenPrioritiesRef.current = hidden; + setHiddenPriorities(hidden); + }; + + // Everything the decision depends on that can be read without a layout. + const layoutKey = (items: HTMLElement[]) => [ + toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isTablet, tabletListVisible, + ...items.map((item) => `${item.dataset.overflowPriority}:${item.className}:${item.textContent}`), + ].join('|'); + const calculate = () => { rafId = null; const items = Array.from(el.querySelectorAll('[data-overflow-item]')); if (items.length === 0) { - setHiddenPriorities(prev => prev.size === 0 ? prev : new Set()); + applyHidden(new Set()); + return; + } + // Opening another message usually leaves the toolbar as it was. Then + // the last answer still holds: apply it without reading any width, + // which would force a layout on every open. + const key = layoutKey(items); + const known = toolbarWidthRef.current !== undefined ? measuredToolbarRef.current.get(key) : undefined; + if (known) { + for (const item of items) item.style.display = known.has(Number(item.dataset.overflowPriority)) ? 'none' : ''; + applyHidden(known); return; } // Sort descending by priority so highest number (least important) is hidden first @@ -1561,25 +1603,34 @@ export function EmailViewer({ leftGroup.style.flexShrink = '0'; rightGroup.style.flexShrink = '0'; el.style.overflow = 'hidden'; - // Iteratively hide items until content fits + // Measure every width in one pass and decide from the numbers (see + // itemsToHide) rather than hiding items one by one and re-reading. + const groupIds = new Map([[leftGroup, 'left'], [rightGroup, 'right']]); + const groupOf = (item: HTMLElement) => { + const parent = item.parentElement; + if (!parent) return ''; + if (!groupIds.has(parent)) groupIds.set(parent, String(groupIds.size)); + return groupIds.get(parent)!; + }; + const measured = items.map((item) => ({ width: item.offsetWidth, group: groupOf(item) })); + const overflow = leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap - (containerWidth + 1); + const gapOf = { + left: parseFloat(getComputedStyle(leftGroup).gap) || 0, + right: parseFloat(getComputedStyle(rightGroup).gap) || 0, + }; const hidden = new Set(); - const isOverflowing = () => - leftGroup.scrollWidth + rightGroup.scrollWidth + mainGap > containerWidth + 1; - for (const item of items) { - if (!isOverflowing()) break; - // Skip items already hidden by CSS (e.g., on mobile) - if (item.offsetWidth === 0) continue; - item.style.display = 'none'; - hidden.add(Number(item.dataset.overflowPriority)); + for (const i of itemsToHide(measured, overflow, gapOf)) { + items[i].style.display = 'none'; + hidden.add(Number(items[i].dataset.overflowPriority)); } // Restore layout leftGroup.style.flexShrink = ''; rightGroup.style.flexShrink = ''; el.style.overflow = ''; - setHiddenPriorities(prev => { - if (prev.size === hidden.size && [...hidden].every(p => prev.has(p))) return prev; - return hidden; - }); + const answers = measuredToolbarRef.current; + if (answers.size >= 8) answers.delete(answers.keys().next().value!); + answers.set(key, hidden); + applyHidden(hidden); }; const scheduleCalculate = () => { @@ -1587,8 +1638,22 @@ export function EmailViewer({ rafId = requestAnimationFrame(calculate); }; - // Recalculate on container resize - const resizeObserver = new ResizeObserver(scheduleCalculate); + // Recalculate on container resize. An observer reports once as soon as it + // starts observing; that first report is the size the synchronous + // calculation below has just measured, so it is not worth a second pass. + // Any other size invalidates the last measurement. + let sawInitialReport = false; + const resizeObserver = new ResizeObserver((entries) => { + const width = entries[entries.length - 1]?.contentRect.width; + const known = toolbarWidthRef.current; + toolbarWidthRef.current = width; + if (!sawInitialReport) { + sawInitialReport = true; + if (width === known) return; + } + measuredToolbarRef.current.clear(); + scheduleCalculate(); + }); resizeObserver.observe(el); // Recalculate when children change (conditional items, label visibility) @@ -1742,13 +1807,13 @@ export function EmailViewer({ setEmailViewDarkOverride(null); setPluginRenderedHtml(null); setPluginRenderedText(null); - setPluginRenderedAttachments([]); + setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); setTnefHtml(null); setTnefText(null); - setTnefAttachments([]); + setTnefAttachments(NO_TNEF_ATTACHMENTS); setEmbeddedEmailHtml(null); setEmbeddedEmailText(null); - setEmbeddedEmailAttachments([]); + setEmbeddedEmailAttachments(NO_POSTAL_ATTACHMENTS); }, [email?.id, externalContentPolicy]); // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin @@ -1758,6 +1823,14 @@ export function EmailViewer({ // via its email-banner slot. Falls through to normal rendering otherwise. useEffect(() => { if (!email) return; + // No plugin renders bodies: nothing to ask, and no async round through the + // hook whose only outcome would be resetting state already reset. + if (renderHooks.onRenderEmailBody.size === 0) { + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); + return; + } let cancelled = false; const dataUrlToBytes = (dataUrl: string): Uint8Array | null => { @@ -1802,7 +1875,7 @@ export function EmailViewer({ if (!result || !result.handledBy) { setPluginRenderedHtml(null); setPluginRenderedText(null); - setPluginRenderedAttachments([]); + setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); return; } setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null); @@ -1822,7 +1895,7 @@ export function EmailViewer({ debug.error('onRenderEmailBody hook failed:', err); setPluginRenderedHtml(null); setPluginRenderedText(null); - setPluginRenderedAttachments([]); + setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS); } })(); @@ -2795,7 +2868,13 @@ export function EmailViewer({ // Gates the quick reply on the iframe having loaded the current srcDoc, so // it doesn't flash in below a still-resizing iframe. - const [iframeReady, setIframeReady] = useState(false); + // The srcDoc whose document has been wired up (handleIframeLoad), or null. + // Readiness is derived from it rather than reset per message: a reset in + // the srcDoc layout effect rendered this component a second time, during + // the commit, on every open. + const [iframeReadyFor, setIframeReadyFor] = useState(null); + const latestSrcDocRef = useRef(null); + const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc; // The plain-text body, where reader-above-quote mounts between text and quote. const [plainBodyEl, setPlainBodyEl] = useState(null); // One object per body: React writes innerHTML again whenever this object @@ -2820,7 +2899,7 @@ export function EmailViewer({ const frameSizeObserverRef = useRef(null); useEffect(() => () => frameSizeObserverRef.current?.disconnect(), []); useLayoutEffect(() => { - setIframeReady(false); + latestSrcDocRef.current = emailIframeSrcDoc; initializedDocRef.current = null; // Runs during commit, before the browser processes the new srcDoc, so // contentDocument here is still the outgoing document. @@ -2902,8 +2981,21 @@ export function EmailViewer({ // Auto-resize iframe to fit content // Measure max(documentElement, body): a height:100% wrapper can leave // documentElement.scrollHeight short while the real content lives in body. - const applyHeight = () => { + // The document as last measured. Re-measuring is not free: the fit + // first clears its own transform and width, which dirties the layout, + // so the read after it lays the whole mail out again. Reading the + // scroll sizes of an untouched document costs nothing, so the poll and + // the observers only pay for the full pass when something changed. + const sizes = () => ({ + width: Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth), + height: Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight), + viewport: doc.documentElement.clientWidth, + }); + let measured = { width: -1, height: -1, viewport: -1 }; + const applyHeight = (force = false) => { if (iframe.contentDocument !== doc) return; // navigated away; stale + const now = sizes(); + if (!force && now.width === measured.width && now.height === measured.height && now.viewport === measured.viewport) return; // Shrink desktop-width mail to fit a phone screen first: it changes // the layout height, and the transform doesn't shrink the measured // (layout) height, so the iframe box has to be scaled by hand. @@ -2913,8 +3005,9 @@ export function EmailViewer({ ); iframe.style.height = height + 'px'; lastBodyHeightRef.current = height; + measured = sizes(); }; - const resizeObserver = new ResizeObserver(applyHeight); + const resizeObserver = new ResizeObserver(() => applyHeight()); resizeObserver.observe(doc.body); // Re-fit when the iframe itself changes width. Height-only changes are // our own applyHeight writing back, so they're ignored - re-entering on @@ -2925,11 +3018,11 @@ export function EmailViewer({ if (iframe.contentDocument !== doc) return; if (iframe.clientWidth === lastFrameWidth) return; lastFrameWidth = iframe.clientWidth; - applyHeight(); + applyHeight(true); }); frameObserver.observe(iframe); frameSizeObserverRef.current = frameObserver; - applyHeight(); + applyHeight(true); // The ResizeObserver only fires on body's border box; a content overflow // that grows scrollHeight without resizing that box (e.g. a height:100% // wrapper, or images that reflow the layout after onload) is otherwise @@ -2943,7 +3036,7 @@ export function EmailViewer({ applyHeight(); }, 200); window.setTimeout(() => window.clearInterval(poll), 2400); - setIframeReady(true); + setIframeReadyFor(latestSrcDocRef.current); // Hide images that fail to load (dead/mixed-content/unreachable external // URLs) rather than leaving the browser's broken-image placeholder and 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..63738940d 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"; @@ -42,6 +43,44 @@ import { useTranslations } from "next-intl"; * it once the row grows a chip line. Extra-compact rows have no avatar and * anchor it on their single line instead (top padding + half a line). */ +/** + * A mouse press on the row itself, i.e. one about to become a click that opens + * it: a touch may be a scroll starting, and a press on a control inside the + * row (checkbox, hover action, link) does something else. + */ +function isMousePressOnRow(e: React.PointerEvent): boolean { + if (e.pointerType !== 'mouse' || e.button !== 0) return false; + return !(e.target as Element).closest('button, input, a'); +} + +/** + * Whether a row has been pointed at yet. The hover actions stay display:none + * until the pointer is over the row, so mounting them for every row up front + * only added a handful of icon buttons per row to every list render. They + * mount on the first pointer-enter and stay from then on, so the hover + * behaviour itself is unchanged. + */ +/** + * Role of the folder the list shows (sender vs recipient, junk actions). In + * aggregate role views the selected mailbox is virtual, so the unified role + * stands in for it. + */ +function listMailboxRole(state: { + mailboxes: { id: string; role?: string | null }[]; + selectedMailbox: string; + isUnifiedView: boolean; + unifiedRole: string | null; +}): string | undefined { + return state.mailboxes.find((mb) => mb.id === state.selectedMailbox)?.role + ?? (state.isUnifiedView ? (state.unifiedRole ?? undefined) : undefined); +} + +function useArmOnPointerEnter(): [boolean, () => void] { + const [armed, setArmed] = React.useState(false); + const arm = React.useCallback(() => setArmed(true), []); + return [armed, arm]; +} + function UnreadDot({ besideAvatar }: { besideAvatar: boolean }) { const t = useTranslations('email_viewer'); return ( @@ -63,7 +102,9 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: { }) { const code = useListVerificationCode(email); const leading = code ? : undefined; - if (onOpenAttachment) { + // Most rows have no attachment: no loader for them, nor an empty chip row. + const mayHaveAttachments = email.hasAttachment || (email.attachments?.length ?? 0) > 0; + if (onOpenAttachment && mayHaveAttachments) { return ; } return leading ?
{leading}
: null; @@ -240,6 +281,8 @@ interface ThreadListItemProps { onCollapseAllThreads?: () => void; onEmailSelect: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; + /** A mouse was pressed on the row: the click that follows will open it. */ + onPrefetch?: (email: Email) => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; onOpenConversation?: (thread: ThreadGroup) => void; onToggleStar?: (email: Email) => void; @@ -258,6 +301,7 @@ interface SingleEmailItemProps { selected: boolean; onClick: () => void; onDoubleClick?: () => void; + onPrefetch?: () => void; onContextMenu?: (e: React.MouseEvent, email: Email) => void; showPreview: boolean; rowTint: string | null; @@ -283,7 +327,7 @@ const SWIPE_ACTION_META: Record, { icon: AppIcon; b }; const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onPrefetch, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); const tHover = useTranslations('settings.email_behavior.hover_actions'); @@ -294,12 +338,20 @@ 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, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({ + selectedMailbox: state.selectedMailbox, + // The role, not the folder list: the list is a new array on every + // count update, and subscribing to it re-rendered every row for each. + currentMailboxRole: listMailboxRole(state), + selectedEmailKeys: state.selectedEmailKeys, + toggleEmailSelection: state.toggleEmailSelection, + selectRangeEmails: state.selectRangeEmails, + clearSelection: state.clearSelection, + isUnifiedView: state.isUnifiedView, + }))); // 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. - const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role - ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const formatAddress = useOwnDomainAddress(); @@ -362,6 +414,7 @@ const SingleEmailItem = React.forwardRef( sourceMailboxId: selectedMailbox, }); + const [hoverActionsArmed, armHoverActions] = useArmOnPointerEnter(); const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress( useCallback((pos) => { onContextMenu?.( @@ -452,6 +505,8 @@ const SingleEmailItem = React.forwardRef( {...dragHandlers} {...touchHandlers} data-testid="email-list-item" + onPointerEnter={armHoverActions} + onPointerDown={(e) => { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(); }} data-email-id={email.id} data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} @@ -770,7 +825,7 @@ const SingleEmailItem = React.forwardRef( {/* Hover Quick Actions */} - {!email.isScheduled && ( + {hoverActionsArmed && !email.isScheduled && ( ({ + selectedMailbox: state.selectedMailbox, + // The role, not the folder list: the list is a new array on every + // count update, and subscribing to it re-rendered every row for each. + currentMailboxRole: listMailboxRole(state), + selectedEmailKeys: state.selectedEmailKeys, + toggleEmailSelection: state.toggleEmailSelection, + selectRangeEmails: state.selectRangeEmails, + clearSelection: state.clearSelection, + isUnifiedView: state.isUnifiedView, + }))); const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined; @@ -858,8 +924,6 @@ export const ThreadListItem = React.forwardRef mb.id === selectedMailbox)?.role - ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const displayNames = showRecipient ? Array.from(new Set( @@ -875,6 +939,7 @@ export const ThreadListItem = React.forwardRef { onContextMenu?.( @@ -914,6 +979,7 @@ export const ThreadListItem = React.forwardRef onEmailSelect(latestEmail)} onDoubleClick={onEmailDoubleClick ? () => onEmailDoubleClick(latestEmail) : undefined} + onPrefetch={onPrefetch ? () => onPrefetch(latestEmail) : undefined} onContextMenu={onContextMenu} showPreview={showPreview} rowTint={rowTint} @@ -1009,6 +1075,8 @@ export const ThreadListItem = React.forwardRef { if (onPrefetch && isMousePressOnRow(e)) onPrefetch(latestEmail); }} aria-current={isSelected ? 'true' : undefined} data-unread={hasUnread ? 'true' : 'false'} data-mv-mobile-row={mvMobileRow ? "" : undefined} @@ -1369,7 +1437,7 @@ export const ThreadListItem = React.forwardRef {/* Hover Quick Actions for thread header */} - {!latestEmail.isScheduled && ( + {hoverActionsArmed && !latestEmail.isScheduled && ( email === b.emails[i]) + && a.participantNames.length === b.participantNames.length + && a.participantNames.every((name, i) => name === b.participantNames[i]); +} + +/** The open message, if this row draws it; otherwise none of the row's business. */ +function openMessageWithin(props: ThreadListItemProps): string | undefined { + const id = props.selectedEmailId; + if (!id) return undefined; + const drawn = props.thread.emails.some((email) => email.id === id) + || (props.expandedEmails?.some((email) => email.id === id) ?? false); + return drawn ? id : undefined; +} + +/** Props equality for MemoizedThreadListItem. */ +export function areThreadRowPropsEqual(prev: ThreadListItemProps, next: ThreadListItemProps): boolean { + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]) as Set; + for (const key of keys) { + if (key === 'thread') { + if (!isSameThread(prev.thread, next.thread)) return false; + } else if (key === 'selectedEmailId') { + if (openMessageWithin(prev) !== openMessageWithin(next)) return false; + } else if (!Object.is(prev[key], next[key])) { + return false; + } + } + return true; +} + +/** + * ThreadListItem for the message list. Opening a message, switching folder or + * a late thread count re-rendered every row on screen; this one re-renders + * only when something it draws has changed. Handlers are compared by + * identity, so the list passes stable ones (see useStableHandlers). + */ +export const MemoizedThreadListItem = React.memo(ThreadListItem, areThreadRowPropsEqual); diff --git a/components/layout/__tests__/sidebar-memo.test.tsx b/components/layout/__tests__/sidebar-memo.test.tsx new file mode 100644 index 000000000..797b9bc93 --- /dev/null +++ b/components/layout/__tests__/sidebar-memo.test.tsx @@ -0,0 +1,62 @@ +import { render, act } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// The mail view renders on every open and every list update. The sidebar +// (the whole folder tree) only renders again when one of its props changes. + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/contexts/drag-drop-context', () => ({ + useDragDropContext: () => ({ isDragging: false }), +})); +vi.mock('@/hooks/use-mailbox-drop', () => ({ + useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }), +})); +vi.mock('@/hooks/use-tag-drop', () => ({ + useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }), +})); +vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null })); +// Called once per render of the sidebar itself: the render counter. +const useRouter = vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() })); +vi.mock('@/i18n/navigation', () => ({ useRouter })); + +const { Sidebar } = await import('../sidebar'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } }, + { id: 'sent', name: 'Sent', role: 'sent', parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; +const onMailboxSelect = () => {}; + +describe('Sidebar re-renders', () => { + beforeEach(() => { + useSettingsStore.setState({ hideAccountSwitcher: true } as never); + }); + + it('skips a parent render that changes none of its props, and follows one that does', () => { + let rerenderParent!: () => void; + let selectFolder!: (id: string) => void; + function Parent() { + const [, setTick] = useState(0); + const [selected, setSelected] = useState('inbox'); + rerenderParent = () => setTick((n) => n + 1); + selectFolder = setSelected; + return ( + + ); + } + render(); + const rendersAfterMount = useRouter.mock.calls.length; + + act(() => rerenderParent()); + expect(useRouter.mock.calls.length).toBe(rendersAfterMount); + + act(() => selectFolder('sent')); + expect(useRouter.mock.calls.length).toBeGreaterThan(rendersAfterMount); + }); +}); 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..4a951bd25 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, Fragment, ReactNode } from "react"; +import { memo, useState, useEffect, useMemo, Fragment, ReactNode } from "react"; import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; @@ -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; @@ -828,7 +829,7 @@ function VacationBanner() { ); } -export function Sidebar({ +function SidebarImpl({ mailboxes = [], selectedMailbox = "", selectedKeyword = 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 @@ -1721,3 +1722,11 @@ export function Sidebar({ ); } + +/** + * The mail sidebar, re-rendered only when one of its props changes. The mail + * view renders on every open and every list update; without this each of + * those rendered the whole folder tree again. Its handlers come from + * useStableHandlers in the mail view, so they keep their identity. + */ +export const Sidebar = memo(SidebarImpl); diff --git a/components/mail/__tests__/mail-app-login-redirect.test.tsx b/components/mail/__tests__/mail-app-login-redirect.test.tsx new file mode 100644 index 000000000..90b9e2611 --- /dev/null +++ b/components/mail/__tests__/mail-app-login-redirect.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; + +// A visitor without a session is sent to the login page. Before anything was +// signed in on this page load that is a client-side navigation: a reload +// downloaded and started the whole app a second time just to show the form. +// After a session ended, the reload stays, since it is what clears the mail. + +const nav = vi.hoisted(() => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn(), prefetch: vi.fn() })); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => nav, + usePathname: () => '/en', + Link: ({ children }: { children: React.ReactNode }) => children, +})); +const hardNav = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + replaceWindowLocation: hardNav, +})); + +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () => null })); + +const { MailApp } = await import('../mail-app'); + +beforeEach(() => { + nav.replace.mockReset(); + hardNav.mockReset(); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + useEmailStore.setState({ emails: [], mailboxes: [], selectedEmail: null } as never); + // checkAuth finding no account to restore. + useAuthStore.setState({ isAuthenticated: false, isLoading: false, client: null, checkAuth: vi.fn().mockResolvedValue(undefined) }); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('mail view without a session', () => { + it('leaves the viewer and editor code alone while sending the visitor to log in', async () => { + const idle = vi.fn(() => 1); + vi.stubGlobal('requestIdleCallback', idle); + vi.stubGlobal('cancelIdleCallback', vi.fn()); + render(); + await act(async () => {}); + expect(nav.replace).toHaveBeenCalledWith('/login'); + expect(idle).not.toHaveBeenCalled(); + }); + + it('opens the login page in place when nothing was signed in yet', async () => { + render(); + await act(async () => {}); + expect(nav.replace).toHaveBeenCalledWith('/login'); + expect(hardNav).not.toHaveBeenCalled(); + }); + + it('reloads into the login page once a session was used on this page', async () => { + act(() => { useAuthStore.setState({ isAuthenticated: true }); }); + act(() => { useAuthStore.setState({ isAuthenticated: false }); }); + render(); + await act(async () => {}); + expect(nav.replace).not.toHaveBeenCalled(); + expect(hardNav).toHaveBeenCalledWith(expect.stringMatching(/\/login$/)); + }); +}); diff --git a/components/mail/__tests__/mail-app-top-bar-actions.test.tsx b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx index 1cebde9a0..dfe073ca9 100644 --- a/components/mail/__tests__/mail-app-top-bar-actions.test.tsx +++ b/components/mail/__tests__/mail-app-top-bar-actions.test.tsx @@ -120,3 +120,15 @@ describe('command palette commands on the mail page', () => { expect(peekParkedAppCommand()).toBeNull(); }); }); + +describe('code warm-up once signed in', () => { + it('fetches the viewer and editor code when the browser is idle', async () => { + const idle = vi.fn(() => 1); + vi.stubGlobal('requestIdleCallback', idle); + vi.stubGlobal('cancelIdleCallback', vi.fn()); + const { unmount } = render(); + await act(async () => {}); + expect(idle).toHaveBeenCalled(); + unmount(); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index f9ae1b1f9..51124c9de 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -28,11 +28,12 @@ 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"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { useEmailStore, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store"; +import { useEmailStore, pendingInitialData, buildUnifiedAccountClients, invalidateUnifiedMailboxes, captureViewToken, ArchiveMailboxNotFoundError, ensureArchiveMailbox, resolveSelectedEmails, resolveUnstampedEmailAccountId, resolveEmailBlobContext, emptyFolderMovesToTrash, bulkDeleteIsPermanent, shouldConfirmBulkDelete } from "@/stores/email-store"; import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { toast } from "@/stores/toast-store"; import { formatRejectedRecipients, type JMAPClient } from "@/lib/jmap/client"; @@ -41,7 +42,8 @@ import { runBatchEmailAction } from "@/lib/email-action-toast"; import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog"; import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster"; import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster"; -import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; +import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInPlace } from '@/stores/auth-store'; +import { useRouter } from '@/i18n/navigation'; import { useSettingsStore } from "@/stores/settings-store"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { useContactStore } from "@/stores/contact-store"; @@ -120,6 +122,9 @@ 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 { useStableHandlers } from "@/hooks/use-stable-handlers"; +import { prefetchEmailContent, requestEmailContent } from "@/lib/email-prefetch"; import { createMailboxWithHooks } from "@/lib/plugin-mail-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; import { emitThreadExpand } from "@/lib/plugin-thread-expand"; @@ -253,13 +258,26 @@ 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(); + // Counts opens, so a slow fetch can tell it was overtaken (handleEmailSelect). + const emailOpenSeqRef = useRef(0); + 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 +360,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, @@ -374,6 +410,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { isLoading, isLoadingEmail, setLoadingEmail, + beginOpenEmail, + finishOpenEmail, + abandonOpenEmail, setPushConnected, handleStateChange, clearNewEmailNotification, @@ -432,7 +471,99 @@ 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, + beginOpenEmail: state.beginOpenEmail, + finishOpenEmail: state.finishOpenEmail, + abandonOpenEmail: state.abandonOpenEmail, + 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 @@ -449,12 +580,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Load recent recipients (from the Sent folder) for compose autocomplete. // Runs once when the Sent mailbox is known; the store guards against reloads. - useEffect(() => { + // Keyed on the folder's id, not the folder list, which changes with every + // count; and started when the browser is idle, since it reads up to 300 + // messages that only the composer needs. + const sentMailboxId = useMemo(() => { const sent = mailboxes.find((m) => m.role === 'sent'); - if (client && sent) { - loadRecentRecipients(client, sent.originalId || sent.id); + return sent ? sent.originalId || sent.id : null; + }, [mailboxes]); + useEffect(() => { + if (!client || !sentMailboxId) return; + const run = () => { void loadRecentRecipients(client, sentMailboxId); }; + if (typeof window.requestIdleCallback === 'function') { + const handle = window.requestIdleCallback(run, { timeout: 5000 }); + return () => window.cancelIdleCallback(handle); } - }, [client, mailboxes, loadRecentRecipients]); + const timer = window.setTimeout(run, 1000); + return () => window.clearTimeout(timer); + }, [client, sentMailboxId, loadRecentRecipients]); // Pro shell: populate per-account mailbox cache so the sidebar can render // every connected account Thunderbird-style. @@ -1125,13 +1267,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Redirect to login if not authenticated + // Redirect to login if not authenticated. Without a session so far this is + // a client-side navigation: reloading would download and start the whole + // app again just to show the form. + const router = useRouter(); useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { saveRedirectAfterLogin(); - redirectToLogin(); + if (canEnterLoginInPlace()) router.replace('/login'); + else redirectToLogin(); } - }, [initialCheckDone, isAuthenticated, authLoading]); + }, [initialCheckDone, isAuthenticated, authLoading, router]); // onBeforeCompose, in front of every way the composer opens. `open` runs // once plugins agreed (straight away when none listens); a plugin's @@ -1283,6 +1429,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const loadData = async (attempt = 1) => { try { + // login() is already loading mailboxes, quota, this folder's page and + // the tag counts for this client: wait for that instead of asking for + // all of it again. + const inFlight = attempt === 1 ? pendingInitialData(client) : undefined; + if (inFlight) { + if (inFlight !== 'done') await inFlight.catch(() => {}); + if (cancelled) return; + if (useEmailStore.getState().mailboxes.length > 0) { + void refreshScheduledMetadata(client); + setInitialMailLoadDone(true); + return; + } + } const needsMailboxes = useEmailStore.getState().mailboxes.length === 0; // Quota only feeds the sidebar meter - never gate the list on it. void fetchQuota(client); @@ -1345,8 +1504,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]); // Warm the code-split viewer/composer chunks once the browser is idle so the - // first message open or compose click doesn't wait on a chunk fetch. + // first message open or compose click doesn't wait on a chunk fetch. Only + // once signed in: a visitor on the way to the login form would otherwise + // download and run the whole editor while that form renders. useEffect(() => { + if (!isAuthenticated) return; const warm = () => { void import("@/components/email/email-viewer"); void import("@/components/email/email-composer"); @@ -1357,7 +1519,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } const id = setTimeout(warm, 1500); return () => clearTimeout(id); - }, []); + }, [isAuthenticated]); // Push notifications: one binding per CONNECTED client, kept for as long as // that client is connected. @@ -3235,9 +3397,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); @@ -3707,6 +3873,40 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }; // Show loading state while checking auth + // The sidebar is memoised; handlers that keep their identity across renders + // are what let it skip the mail view's renders (opening a message, a list + // update), which change nothing it shows. + const sidebarHandlers = useStableHandlers({ + onOpenStarred: mvShell ? goToStarred : undefined, + onMailboxSelect: handleMailboxSelect, + onTagSelect: handleTagSelect, + onUnreadFilterClick: handleUnreadFilterClick, + onMarkFolderRead: handleMarkFolderRead, + onMarkFolderTreeRead: handleMarkFolderTreeRead, + onMarkAllFoldersRead: handleMarkAllFoldersRead, + onEmptyFolder: handleEmptyFolderFromContextMenu, + onCreateSubfolder: handleCreateSubfolderFromContextMenu, + onCreateFolder: handleCreateFolderFromContextMenu, + onRenameFolder: handleRenameFolderFromContextMenu, + onDeleteFolder: handleDeleteFolderFromContextMenu, + onShareFolder: supportsMailboxSharing ? handleShareFolderFromContextMenu : undefined, + onImportEmail: handleImportEmailFromContextMenu, + onRefreshMailboxes: handleRefreshMailboxes, + onCompose: () => { + void withComposeGate('compose', {}, () => { + startFreshComposerSession(); + setComposerMode('compose'); + setShowComposer(true); + if (isMobile) { + setSidebarOpen(false); + setActiveView('viewer'); + } + }); + }, + onSidebarClose: () => setSidebarOpen(false), + onAccountMailboxSelect: handleAccountMailboxSelect, + }); + if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) { return (
@@ -3741,6 +3941,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent; // Handle email selection with mobile view switching + // A mouse pressed on a row: the click that follows opens the message, so + // its content is requested now (see lib/email-prefetch). Resolved the way + // handleEmailSelect resolves it, so the open finds this very request. + const handleEmailPrefetch = (email: Email) => { + if (!client) return; + const { client: fetchClient, accountId } = resolveEmailBlobContext(email, client); + if (fetchClient) prefetchEmailContent(fetchClient, email.id, accountId); + }; + const handleEmailSelect = async ( email: { id: string } & Partial>, ) => { @@ -3754,9 +3963,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setShowComposer(false); } - // Find the list-level email for metadata (accountId, scheduled flags, etc.) - // but don't select it yet — wait for the full fetch to avoid a toolbar flash - // caused by rendering with the stub (no bodyValues) then re-rendering with the full email. + // Find the list-level email for metadata (accountId, scheduled flags, etc.). // // JMAP email ids are only unique per account (RFC 8621 §1.3) and Stalwart // hands out overlapping id ranges, so in aggregate views one bare id can name @@ -3771,7 +3978,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { && (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId)) ?? (email.sourceClientAccountId ? email : undefined); - setLoadingEmail(true); + // Only the newest open may touch the viewer: a slow fetch for a message + // the user has already clicked away from must neither replace the one + // they are looking at nor end its loading state. + const openSeq = ++emailOpenSeqRef.current; + const previousEmail = useEmailStore.getState().selectedEmail; + // Paint the header from the row at once (subject, sender and date are in + // hand) and let the body follow; the viewer shows a row without body + // values as loading. Mountain View shows the message inside its + // conversation, which is fetched alongside, so it waits for both. + const rowEmail = !mvConversationView && listEmail && 'receivedAt' in listEmail ? listEmail as Email : null; + if (rowEmail) { + beginOpenEmail(rowEmail); + } else { + setLoadingEmail(true); + } // On mobile, switch to viewer if (isMobile) { @@ -3788,6 +4009,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { client: fetchClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client); if (!fetchClient) { console.warn('[mail-app] No connected client for email source'); + if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail); return; } @@ -3796,7 +4018,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const conversationPrefetch = mvConversationView && listEmail?.id && listEmail.threadId ? loadMvConversation(listEmail as Email, client) : null; - const fullEmail = await fetchClient.getEmail(email.id, accountId); + // The request a mouse press on the row already started, if any. + const fullEmail = await requestEmailContent(fetchClient, email.id, accountId); + if (openSeq !== emailOpenSeqRef.current) return; if (fullEmail) { if (listEmail?.isScheduled) { fullEmail.scheduledSendAt = listEmail.scheduledSendAt; @@ -3825,14 +4049,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { new Promise((resolve) => setTimeout(() => resolve(undefined), MV_CONVERSATION_WAIT_MS)), ]); if (conversation !== undefined) setMvConversation(conversation); + if (openSeq !== emailOpenSeqRef.current) return; } - selectEmail(fullEmail); + // One write: the message, and the end of its loading state. + finishOpenEmail(fullEmail); // Mark-as-read logic is now handled by useEffect + } else { + abandonOpenEmail(previousEmail); } } catch (error) { console.error('Failed to fetch email content:', error); - } finally { - setLoadingEmail(false); + if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail); } }; @@ -4222,40 +4449,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { crossAccountActive={crossAccountActive} showCrossUnread={showCrossUnread} showCrossStarred={showCrossStarred} - onOpenStarred={mvShell ? goToStarred : undefined} showCrossAll={showCrossAll} crossUnreadCount={crossUnreadCount} - onMailboxSelect={handleMailboxSelect} - onTagSelect={handleTagSelect} - onUnreadFilterClick={handleUnreadFilterClick} - onMarkFolderRead={handleMarkFolderRead} - onMarkFolderTreeRead={handleMarkFolderTreeRead} - onMarkAllFoldersRead={handleMarkAllFoldersRead} - onEmptyFolder={handleEmptyFolderFromContextMenu} - onCreateSubfolder={handleCreateSubfolderFromContextMenu} - onCreateFolder={handleCreateFolderFromContextMenu} - onRenameFolder={handleRenameFolderFromContextMenu} - onDeleteFolder={handleDeleteFolderFromContextMenu} - onShareFolder={supportsMailboxSharing ? handleShareFolderFromContextMenu : undefined} - onImportEmail={handleImportEmailFromContextMenu} - onRefreshMailboxes={handleRefreshMailboxes} - onCompose={() => { - void withComposeGate('compose', {}, () => { - startFreshComposerSession(); - setComposerMode('compose'); - setShowComposer(true); - if (isMobile) { - setSidebarOpen(false); - setActiveView('viewer'); - } - }); - }} - onSidebarClose={() => setSidebarOpen(false)} + {...sidebarHandlers} mvShell={mvShell} multiAccountMode={isEmbedded} accountMailboxes={accountMailboxes} viewingAccountId={viewingAccountId} - onAccountMailboxSelect={handleAccountMailboxSelect} />
@@ -4686,6 +4886,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }} onEmailSelect={handleEmailSelect} + onEmailPrefetch={handleEmailPrefetch} onEmailDoubleClick={isEmbedded ? ((email) => { const store = useProTabStore.getState(); const hasSplit = store.tabs.some((tab) => tab.paneId === 'split'); 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 && (