From 84a53e9ea3668460f3c73236db02c0679e2d93ab Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:20:56 +0200 Subject: [PATCH] perf: stop re-rendering the folder sidebar when a message opens --- .../layout/__tests__/sidebar-memo.test.tsx | 62 ++++++++++++++++++ components/layout/sidebar.tsx | 12 +++- components/mail/mail-app.tsx | 64 +++++++++++-------- 3 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 components/layout/__tests__/sidebar-memo.test.tsx 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/sidebar.tsx b/components/layout/sidebar.tsx index 9f7c279b2..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"; @@ -829,7 +829,7 @@ function VacationBanner() { ); } -export function Sidebar({ +function SidebarImpl({ mailboxes = [], selectedMailbox = "", selectedKeyword = null, @@ -1722,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/mail-app.tsx b/components/mail/mail-app.tsx index 61693177e..cac337525 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -122,6 +122,7 @@ 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"; @@ -3853,6 +3854,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 (
@@ -4395,40 +4430,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} />