fix: stop remounting message-details plugin slots on every render

This commit is contained in:
Linus Rath
2026-10-14 02:56:15 +02:00
parent 2adb1e2b12
commit 03e9846758
2 changed files with 103 additions and 22 deletions
@@ -0,0 +1,71 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React, { useEffect } from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
// The "more details" panel defined its plugin slot component inside render,
// so every re-render of the viewer was a new component type: React unmounted
// and remounted the plugin's iframe each time (and re-ran its shouldShow).
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
const mounts: Record<string, number> = {};
const propsSeen: Record<string, unknown[]> = {};
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: unknown }) => {
(propsSeen[name] ??= []).push(extraProps);
useEffect(() => {
mounts[name] = (mounts[name] ?? 0) + 1;
}, []);
return React.createElement('div', { 'data-slot': name });
},
}));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
usePluginSlotOffers: (slot: string) =>
slot === 'email-details-section' || slot === 'email-banner' ? [{ pluginId: 'p', order: 100 }] : [],
}));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [], htmlBody: [], attachments: [], bodyValues: {},
} as unknown as Email;
describe('plugin slots in the message viewer', () => {
it('keep their iframes and props across re-renders', () => {
const view = (extra: Record<string, unknown>) => (
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
);
const { rerender } = render(view({ isLoading: false }));
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
const detailMounts = mounts['email-details-section'];
const bannerProps = propsSeen['email-banner'].at(-1);
expect(detailMounts).toBeGreaterThan(0);
rerender(view({ isLoading: false, onReply: () => {} }));
rerender(view({ isLoading: false, onReply: () => {}, onForward: () => {} }));
expect(mounts['email-details-section']).toBe(detailMounts);
expect(propsSeen['email-banner'].at(-1)).toBe(bannerProps);
});
});
+32 -22
View File
@@ -26,6 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView } from "@/lib/plugin-types";
import { generateEmailSource } from "@/lib/email-source";
import {
Reply,
@@ -951,6 +952,15 @@ function MvPager({
);
}
/**
* The plugin slot under one "more details" category. Props are memoised so
* re-renders of the viewer don't post them to the plugin again.
*/
function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; category: string | null }) {
const props = useMemo(() => ({ email, category }), [email, category]);
return <PluginSlot name="email-details-section" className="mt-2 empty:mt-0" extraProps={props} />;
}
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
<div>
@@ -1390,6 +1400,16 @@ export function EmailViewer({
// Whether any plugin offers a "more details" section, so we only render the
// bottom plugin category wrapper when something will fill it.
const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0;
const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0;
// One projected view per message for all plugin slots on it, built only
// when a plugin is there to see it. A fresh object on every render made
// each slot post its props to the plugin again and re-run shouldShow.
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers;
const pluginEmailView = useMemo(
() => (email && wantsPluginEmailView ? emailToReadView(email) : null),
[email, wantsPluginEmailView],
);
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
@@ -4917,20 +4937,13 @@ export function EmailViewer({
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
// Projected, read-only view handed to plugins that render in the
// "more details" panel. Includes the parsed `headers` map and full
// `source` so plugins can inspect raw headers / message source.
// Built lazily here - only when the details panel is expanded.
const detailsView = emailToReadView(email);
// Lets a plugin add rows under an existing category. The plugin's
// own `shouldShow({ email, category })` decides which category it
// appears under (or `category === null` for the new bottom section).
const CategorySlot = ({ category }: { category: string }) => (
<PluginSlot
name="email-details-section"
className="mt-2 empty:mt-0"
extraProps={{ email: detailsView, category }}
/>
// A component defined here would be a new type on every render and
// remount the plugin's iframe each time, so it lives outside.
const categorySlot = (category: string) => (
<DetailsCategorySlot email={pluginEmailView} category={category} />
);
return (
@@ -5004,7 +5017,7 @@ export function EmailViewer({
)}
</Row>
</dl>
<CategorySlot category="recipients_routing" />
{categorySlot("recipients_routing")}
</section>
{hasAuthSection && (
@@ -5090,7 +5103,7 @@ export function EmailViewer({
</div>
</div>
)}
<CategorySlot category="authentication_security" />
{categorySlot("authentication_security")}
</section>
)}
@@ -5125,7 +5138,7 @@ export function EmailViewer({
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
<CategorySlot category="identifiers_threading" />
{categorySlot("identifiers_threading")}
</section>
)}
@@ -5153,7 +5166,7 @@ export function EmailViewer({
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
<CategorySlot category="message_properties" />
{categorySlot("message_properties")}
</section>
{hasListInfo && (
@@ -5179,7 +5192,7 @@ export function EmailViewer({
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
<CategorySlot category="mailing_list" />
{categorySlot("mailing_list")}
</section>
)}
@@ -5187,10 +5200,7 @@ export function EmailViewer({
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<PluginSlot
name="email-details-section"
extraProps={{ email: detailsView, category: null }}
/>
<DetailsCategorySlot email={pluginEmailView} category={null} />
</section>
)}
</div>
@@ -5373,7 +5383,7 @@ export function EmailViewer({
</div>
)}
<PluginSlot name="email-banner" extraProps={{ email: emailToReadView(email) }} />
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
@@ -6103,7 +6113,7 @@ export function EmailViewer({
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={{ email: emailToReadView(email) }} />
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
</div>
</div>
</>