diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx
index ae430177e..e94dd4f89 100644
--- a/components/boot-skeleton/mail-skeleton.tsx
+++ b/components/boot-skeleton/mail-skeleton.tsx
@@ -400,43 +400,55 @@ function MobileHeaderSkeleton() {
* same component): the toolbar, the subject block and the sender block of the
* default reader, then the message body's tinted sheet.
*/
-export function MailMessageSkeleton({ className }: { className?: string }) {
+/**
+ * The reader's top toolbar while a message loads. The loaded reader lifts its
+ * toolbar above the message row on a tablet or desktop (EmailViewer's
+ * `liftToolbar`); its loading state draws this there too, so the row below
+ * doesn't move when the message arrives.
+ */
+export function MailMessageToolbarSkeleton() {
const button = "h-8 w-20 rounded-md";
return (
-
-
-
- {/* Phones: back, then icon-over-label actions. */}
-
-
+
+
+ {/* Phones: back, then icon-over-label actions. */}
+
+
+
+
+ {repeat(6, (i) => (
+
+
+
+
+ ))}
+
+
+
+ {/* Back to the list, where the reader has the list's place (tablet, focus). */}
+
- {repeat(6, (i) => (
-
-
-
-
- ))}
+
+
+
-
-
- {/* Back to the list, where the reader has the list's place (tablet, focus). */}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ );
+}
+
+export function MailMessageSkeleton({ className, toolbar = true }: { className?: string; toolbar?: boolean }) {
+ return (
+
+ {toolbar ?
: null}
diff --git a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx
index 37b707f9e..47c196759 100644
--- a/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx
+++ b/components/email/__tests__/viewer-toolbar-over-side-panel.test.tsx
@@ -131,6 +131,33 @@ describe('the reader toolbar and the plugin side panel', () => {
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
+ it('loads a message with its toolbar where the loaded reader puts it', () => {
+ // The loading state lifts the toolbar above the message row too, so the
+ // row stays put when the message arrives: the same element at the same
+ // place (a moved row counts as a layout shift).
+ const view = (props: { email: Email | null; isLoading: boolean }) => (
+
{}} {...props} />
+ );
+ const { container, rerender } = render(view({ email: null, isLoading: true }));
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.children).toHaveLength(2);
+ const row = root.children[1];
+ expect(row.className).toContain('flex-row');
+ expect(root.children[0].querySelector('.sk-shape')).toBeTruthy();
+ rerender(view({ email, isLoading: false }));
+ expect(root.querySelector('[data-viewer-top-toolbar="full-width"]')).toBe(root.children[0]);
+ expect(root.children[1]).toBe(row);
+ });
+
+ it('loads a message on a phone with the toolbar in the message column', () => {
+ device.isMobile = true;
+ const { container } = render(
+ {}} />,
+ );
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.children).toHaveLength(1);
+ });
+
it('keeps the toolbar in the message column on a phone', () => {
device.isMobile = true;
show();
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index fbcc84542..8c3167aa9 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -117,7 +117,7 @@ import {
Pencil,
Paperclip,
} from "@/components/icons";
-import { MailMessageSkeleton } from "@/components/boot-skeleton/mail-skeleton";
+import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -3930,12 +3930,12 @@ ${printWatermark ? printWatermark.html : ''}
)}
>
);
- const shell = (content: React.ReactNode) => (
+ const shell = (content: React.ReactNode, top: React.ReactNode = null) => (
- {null}
+ {top}
{content}
{sidePanel}
@@ -3947,8 +3947,11 @@ ${printWatermark ? printWatermark.html : ''}
// Show loading skeleton while email is being fetched: the page skeleton's
// message pane, so a linked message keeps the shape it had before the app
// started.
+ // Its toolbar sits where the loaded reader puts its own: above the message
+ // row when lifted, so the row doesn't move when the message arrives.
if (isLoading && !email) {
- return shell();
+ const lifted = toolbarPosition === 'top' && liftToolbar;
+ return shell(, lifted ? : null);
}
if (!email) {