diff --git a/components/email/__tests__/reading-mode-toggle.test.tsx b/components/email/__tests__/reading-mode-toggle.test.tsx new file mode 100644 index 000000000..091740c7b --- /dev/null +++ b/components/email/__tests__/reading-mode-toggle.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// #964: the reading-mode (Sun/Moon) toggle used to mount only once the body +// had loaded and turned out to be HTML. That late mount changed the toolbar +// width and re-ran the overflow calculation, so the other buttons jumped. +// The toggle must be in the DOM from the start, merely disabled. + +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() }), +})); + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ + usePluginSlotOffers: () => [], +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes: Mailbox[] = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function makeEmail(overrides: Partial = {}): Email { + return { + 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: {}, + ...overrides, + } as unknown as Email; +} + +function findToggle(): HTMLElement | null { + return ( + screen.queryByTitle('View in dark mode') ?? screen.queryByTitle('View in light mode') + ); +} + +describe('reading-mode toggle stays mounted (#964)', () => { + it('is in the DOM while the body is still loading, disabled', () => { + render( + , + ); + + const toggle = findToggle(); + expect(toggle).not.toBeNull(); + expect(toggle).toBeDisabled(); + }); + + it('is enabled once the HTML body is available', () => { + render( + Hi

', isTruncated: false } }, + } as never)} + mailboxes={mailboxes} + selectedMailbox="inbox" + currentMailboxRole="inbox" + />, + ); + + const toggle = findToggle(); + expect(toggle).not.toBeNull(); + expect(toggle).not.toBeDisabled(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 8ee35b017..0182c761f 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3218,8 +3218,10 @@ export function EmailViewer({ - {/* Dark/light mode toggle for HTML emails */} - {effectiveEmailContent.isHtml && ( + {/* Dark/light mode toggle for HTML emails. Always mounted: `isHtml` + flips from false to true once the body arrives, and mounting the + button then changes the toolbar width and re-runs the overflow + calculation, so the other buttons jump. Disable it instead. (#964) */} - )} {/* Fullscreen toggle - hidden on mobile (already fullscreen there). Never overflows into the More menu: in fullscreen this button is