From d2ad8d5d2fcc741e62f10e3e712d70f0ae3419ac Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:29:24 +0200 Subject: [PATCH] perf: stop re-rendering message rows on folder count updates --- .../thread-row-store-renders.test.tsx | 47 +++++++++++++++++++ components/email/thread-list-item.tsx | 33 +++++++++---- 2 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 components/email/__tests__/thread-row-store-renders.test.tsx 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/thread-list-item.tsx b/components/email/thread-list-item.tsx index 0cbc07c99..b0b825bba 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -60,6 +60,21 @@ function isMousePressOnRow(e: React.PointerEvent): boolean { * 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), []); @@ -321,21 +336,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(useShallow((state) => ({ + const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({ selectedMailbox: state.selectedMailbox, - mailboxes: state.mailboxes, + // 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, - unifiedRole: state.unifiedRole, }))); // In Sent/Drafts folders, show recipient instead of sender (which is always // "me"). In aggregate role-views the selected mailbox is virtual → fall back // to the unified role so junk-contextual UI and avatar hiding work. - 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(); @@ -889,15 +903,16 @@ export const ThreadListItem = React.forwardRef ({ + const { selectedMailbox, currentMailboxRole, selectedEmailKeys, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore(useShallow((state) => ({ selectedMailbox: state.selectedMailbox, - mailboxes: state.mailboxes, + // 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, - unifiedRole: state.unifiedRole, }))); const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); @@ -907,8 +922,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(