perf: stop re-rendering message rows on folder count updates

This commit is contained in:
Linus Rath
2026-10-15 02:29:24 +02:00
parent 97e13a199c
commit d2ad8d5d2f
2 changed files with 70 additions and 10 deletions
@@ -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(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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();
});
});
+23 -10
View File
@@ -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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, ThreadListItemPro
? formatScheduledSendAt(latestEmail.scheduledSendAt, timeFormat)
: null;
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,
})));
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
@@ -907,8 +922,6 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). Aggregate role-views use a virtual selected mailbox → 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 displayNames = showRecipient
? Array.from(new Set(