From 03e9846758f2f52d386e0125d388edcdea3cbae9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:56:15 +0200 Subject: [PATCH] fix: stop remounting message-details plugin slots on every render --- .../viewer-plugin-slot-remount.test.tsx | 71 +++++++++++++++++++ components/email/email-viewer.tsx | 54 ++++++++------ 2 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 components/email/__tests__/viewer-plugin-slot-remount.test.tsx diff --git a/components/email/__tests__/viewer-plugin-slot-remount.test.tsx b/components/email/__tests__/viewer-plugin-slot-remount.test.tsx new file mode 100644 index 000000000..ac009b4bb --- /dev/null +++ b/components/email/__tests__/viewer-plugin-slot-remount.test.tsx @@ -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 = {}; +const propsSeen: Record = {}; +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) => ( + + ); + 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); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 7da9207fd..030af2e87 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 ; +} + function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) { return (
@@ -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(() => { 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 }) => ( - + // 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) => ( + ); return ( @@ -5004,7 +5017,7 @@ export function EmailViewer({ )} - + {categorySlot("recipients_routing")} {hasAuthSection && ( @@ -5090,7 +5103,7 @@ export function EmailViewer({
)} - + {categorySlot("authentication_security")} )} @@ -5125,7 +5138,7 @@ export function EmailViewer({ {email.threadId} )} - + {categorySlot("identifiers_threading")} )} @@ -5153,7 +5166,7 @@ export function EmailViewer({ {email.accountLabel} )} - + {categorySlot("message_properties")} {hasListInfo && ( @@ -5179,7 +5192,7 @@ export function EmailViewer({ {listHeaders.listPost} )} - + {categorySlot("mailing_list")} )} @@ -5187,10 +5200,7 @@ export function EmailViewer({ `category === null` render their own titled section here. */} {hasDetailsSlotOffers && (
- +
)} @@ -5373,7 +5383,7 @@ export function EmailViewer({ )} - + {/* === ATTACHMENTS below header (below-header mode, desktop only) === */} {attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && ( @@ -6103,7 +6113,7 @@ export function EmailViewer({
- +