From c633ce1ff4845a7f1bd790f8b6a930e4e09ad703 Mon Sep 17 00:00:00 2001
From: Linus Rath
Date: Mon, 7 Sep 2026 22:34:47 +0200
Subject: [PATCH] fix: keep the reading-mode toggle mounted so toolbar buttons
stop jumping #964
---
.../__tests__/reading-mode-toggle.test.tsx | 102 ++++++++++++++++++
components/email/email-viewer.tsx | 9 +-
2 files changed, 108 insertions(+), 3 deletions(-)
create mode 100644 components/email/__tests__/reading-mode-toggle.test.tsx
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