fix: keep the reading-mode toggle mounted so toolbar buttons stop jumping #964

This commit is contained in:
Linus Rath
2026-09-07 22:34:47 +02:00
parent ab4bf91b20
commit c633ce1ff4
2 changed files with 108 additions and 3 deletions
@@ -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> = {}): 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(
<EmailViewer
email={makeEmail({ htmlBody: [{ partId: '1', type: 'text/html' }] } as never)}
isLoading
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
/>,
);
const toggle = findToggle();
expect(toggle).not.toBeNull();
expect(toggle).toBeDisabled();
});
it('is enabled once the HTML body is available', () => {
render(
<EmailViewer
email={makeEmail({
htmlBody: [{ partId: '1', type: 'text/html' }],
bodyValues: { '1': { value: '<p>Hi</p>', isTruncated: false } },
} as never)}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
/>,
);
const toggle = findToggle();
expect(toggle).not.toBeNull();
expect(toggle).not.toBeDisabled();
});
});
+6 -3
View File
@@ -3218,8 +3218,10 @@ export function EmailViewer({
<Code className="w-4 h-4" />
</Button>
{/* 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) */}
<Button
variant="ghost"
size="sm"
@@ -3228,10 +3230,11 @@ export function EmailViewer({
data-overflow-priority="11"
className="hidden sm:inline-flex h-8 gap-1.5"
title={isDark ? 'View in light mode' : 'View in dark mode'}
disabled={!effectiveEmailContent.isHtml}
aria-disabled={!effectiveEmailContent.isHtml}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
)}
{/* Fullscreen toggle - hidden on mobile (already fullscreen there).
Never overflows into the More menu: in fullscreen this button is