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}
/>