mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
perf: stop re-rendering message rows on folder count updates
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user