mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: stop re-rendering the folder sidebar when a message opens
This commit is contained in:
@@ -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 (
|
||||
<Sidebar mailboxes={mailboxes} selectedMailbox={selected} onMailboxSelect={onMailboxSelect} />
|
||||
);
|
||||
}
|
||||
render(<Parent />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
@@ -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}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user